diff --git a/PRODUCT.md b/PRODUCT.md
index a545b51..e7d25d2 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -26,7 +26,7 @@ Most radio apps play streams but discard what's on them. SatsAmp treats the live
## Operating Context
- Single-user personal device use; headphone and speaker listening.
-- Streams are live and can drop; playback is a foreground service with notification controls. Swiping the task away stops playback and clears the notification (closed means stopped); screen-off listening with the task alive keeps playing.
+- Streams are live and can drop; playback is a foreground service with notification controls.
- Library is local (Room DB, `radamp.db`); saved songs, playlists, and imported files live on-device.
- Stations are built-in defaults plus user-added streams (direct URL or M3U playlist import).
@@ -45,7 +45,7 @@ Most radio apps play streams but discard what's on them. SatsAmp treats the live
## Evidence on Hand
-- 11 built-in stations (cliamp.stream music channels in playlist order: Lofi → … → NCS Chill), all verified live 2026-09-09 (HTTP 200 + audio payload; probe results were ephemeral, not committed). Media Monarchy ships as a built-in stream in the Streams tab instead (talk, not music).
+- 43 built-in stations, all verified live 2026-09-07 (HTTP 200 + audio payload; probe results were ephemeral in /tmp, not committed).
- Working recorder pipeline (`recording/`: IcyStreamReader, SongBoundaryDetector, RollingAudioBuffer, MetadataEmbedder).
- Design scratch work in `design/` (glass theme CSS + demo).
- Absences that must not be fabricated: no testimonials, no user base, no press, no pricing, no privacy policy text.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 6b2bf88..e8dfd23 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -104,15 +104,9 @@ dependencies {
// not exist on Maven Central), which made the build fail during
// dependency resolution before Kotlin was ever compiled.
implementation("net.jthink:jaudiotagger:2.2.5")
- // Nostr/Lightning crypto: BIP-340 schnorr + ECDH via ACINQ's secp256k1
- // Kotlin-multiplatform binding (prebuilt .so for all Android ABIs).
- implementation("fr.acinq.secp256k1:secp256k1-kmp:0.17.3")
- implementation("fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.17.3")
// Testing
testImplementation("junit:junit:4.13.2")
- testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm-extract:0.17.3")
- testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm-linux:0.17.3")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index bb952aa..040a549 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,13 +1,2 @@
-dontwarn javax.annotation.**
-keep class com.rada.data.local.entity.** { *; }
-
-# jaudiotagger maps tag frames via reflection; without keeps the release
-# minifier breaks its writer (silent NPE, files saved untagged).
--keep class org.jaudiotagger.** { *; }
--keepattributes Signature, InnerClasses, EnclosingMethod
-# jaudiotagger also references desktop-only image/test APIs that Android
-# never executes (artwork scaling, test helpers).
--dontwarn org.jaudiotagger.**
--dontwarn java.awt.**
--dontwarn javax.imageio.**
--dontwarn javax.swing.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5df91ae..3285b91 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,16 +8,6 @@
-
-
-
-
-
-
-
-
-
-
>
- /**
- * Union of songs across several playlists. DISTINCT because a song can
- * sit in more than one selected list.
- */
- @Query(
- """
- SELECT DISTINCT s.* FROM saved_songs s
- INNER JOIN playlist_songs ps ON s.id = ps.songId
- WHERE ps.playlistId IN (:playlistIds)
- """
- )
- fun getSongsInPlaylists(playlistIds: List): Flow>
-
@Query("SELECT COUNT(*) FROM playlist_songs WHERE playlistId = :playlistId")
fun getPlaylistSongCount(playlistId: Long): Flow
diff --git a/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt b/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt
index a0f6761..6a76890 100644
--- a/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt
+++ b/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt
@@ -14,9 +14,6 @@ interface SavedSongDao {
@Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC")
fun getAllByDate(): Flow>
- @Query("SELECT * FROM saved_songs ORDER BY id ASC")
- suspend fun getAllOnce(): List
-
@Query("SELECT * FROM saved_songs ORDER BY artist ASC, title ASC")
fun getAllByArtist(): Flow>
diff --git a/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt b/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt
index cafbe77..be6e767 100644
--- a/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt
+++ b/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt
@@ -18,14 +18,4 @@ data class SavedSongEntity(
val format: String = "mp3",
val dateSaved: Long = System.currentTimeMillis(),
val isFavorite: Boolean = false,
- /** Track origin: "local" (default) or "wavlake" (streamable catalog import). */
- val source: String = "local",
- /** Canonical source URL (Wavlake track page). Empty for local files. */
- val sourceUrl: String = "",
- /** Artwork URL (Wavlake imports). Empty for local files. */
- val artworkUrl: String = "",
- /** Wavlake artist id (imports only) — links plays back to zap addresses. */
- val wavlakeArtistId: String = "",
- /** Wavlake artist npub (imports only) — enables Nostr address resolution. */
- val wavlakeArtistNpub: String = "",
)
diff --git a/app/src/main/java/com/rada/data/repository/DedupeLibrary.kt b/app/src/main/java/com/rada/data/repository/DedupeLibrary.kt
deleted file mode 100644
index 2171301..0000000
--- a/app/src/main/java/com/rada/data/repository/DedupeLibrary.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-package com.rada.data.repository
-
-import android.util.Log
-import com.rada.data.local.dao.PlaylistDao
-import com.rada.data.local.dao.SavedSongDao
-import com.rada.data.local.entity.SavedSongEntity
-import java.io.File
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Collapses duplicate library songs (same artist + title, case-insensitive).
- * Duplicates arise mainly from save-from-air: saving the same broadcast twice
- * wrote a second file ("Song (1).mp3") and a second row.
- *
- * Keep rule: the largest file (the most complete recording; equal sizes tie
- * to the oldest entry). Dropped songs' playlist links are re-pointed to the
- * kept song before their rows and files are deleted.
- */
-@Singleton
-class DedupeLibrary @Inject constructor(
- private val savedSongDao: SavedSongDao,
- private val playlistDao: PlaylistDao,
-) {
- data class Result(val removed: Int)
-
- suspend fun run(): Result {
- val all = savedSongDao.getAllOnce()
- val groups = all.groupBy {
- "${it.artist.trim().lowercase()}${it.title.trim().lowercase()}"
- }
- var removed = 0
- for ((_, group) in groups) {
- if (group.size < 2) continue
- // Largest file wins; ties → oldest (smallest id).
- val keeper = group.maxWithOrNull(
- compareBy { it.fileSize }.thenBy { -it.id }
- ) ?: continue
- for (dup in group.filter { it.id != keeper.id }) {
- // Preserve group membership: re-point links, skip if the
- // keeper is already in that playlist.
- for (playlistId in playlistDao.getPlaylistIdsForSong(dup.id)) {
- if (!playlistDao.isSongInPlaylist(playlistId, keeper.id)) {
- playlistDao.addSongToPlaylist(
- com.rada.data.local.entity.PlaylistSongEntity(
- playlistId = playlistId,
- songId = keeper.id,
- ),
- )
- }
- }
- savedSongDao.deleteById(dup.id)
- removed++
- try {
- if (dup.filePath != keeper.filePath) File(dup.filePath).delete()
- } catch (_: Exception) {
- }
- }
- Log.d(TAG, "Deduped ${group.size - 1} copy(ies) of " +
- "${keeper.artist} — ${keeper.title}")
- }
- return Result(removed)
- }
-
- companion object {
- private const val TAG = "DedupeLibrary"
- }
-}
diff --git a/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt b/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt
index 456c193..24f8f28 100644
--- a/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt
+++ b/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt
@@ -45,11 +45,6 @@ class PlaylistRepository @Inject constructor(
return playlistDao.getSongsInPlaylist(playlistId)
}
- /** Union of songs across several playlists (for multi-list shuffle). */
- fun getSongsInPlaylists(playlistIds: List): Flow> {
- return playlistDao.getSongsInPlaylists(playlistIds)
- }
-
fun getPlaylistSongCount(playlistId: Long): Flow {
return playlistDao.getPlaylistSongCount(playlistId)
}
diff --git a/app/src/main/java/com/rada/data/repository/StationRepository.kt b/app/src/main/java/com/rada/data/repository/StationRepository.kt
index 5421fbd..efee1bf 100644
--- a/app/src/main/java/com/rada/data/repository/StationRepository.kt
+++ b/app/src/main/java/com/rada/data/repository/StationRepository.kt
@@ -24,10 +24,6 @@ class StationRepository @Inject constructor(
private val stations = mutableListOf()
private val nameOverrides = mutableMapOf()
- /** Built-in station ids the user deleted. Persisted; survives restarts. */
- private val _hiddenIds = MutableStateFlow(loadHiddenIds())
- val hiddenIds: StateFlow> = _hiddenIds.asStateFlow()
-
init {
nameOverrides.putAll(loadNameOverrides())
rebuild()
@@ -42,42 +38,10 @@ class StationRepository @Inject constructor(
saveCustomStations()
}
- /**
- * Deletes any station. Custom stations are removed; built-ins are
- * hidden (persisted) so they stay gone across restarts. See
- * [unhideStation]/[restoreHiddenStations] for the way back.
- */
fun removeStation(id: String) {
- if (DefaultStations.getById(id) != null) {
- if (id !in _hiddenIds.value) {
- _hiddenIds.value = _hiddenIds.value + id
- saveHiddenIds(_hiddenIds.value)
- rebuild()
- }
- return
- }
stations.removeAll { it.id == id }
if (nameOverrides.remove(id) != null) saveNameOverrides()
saveCustomStations()
- rebuild()
- }
-
- /** Un-hides one previously hidden built-in station. No-op otherwise. */
- fun unhideStation(id: String) {
- if (id in _hiddenIds.value) {
- _hiddenIds.value = _hiddenIds.value - id
- saveHiddenIds(_hiddenIds.value)
- rebuild()
- }
- }
-
- /** Restores all hidden built-in stations. */
- fun restoreHiddenStations() {
- if (_hiddenIds.value.isNotEmpty()) {
- _hiddenIds.value = emptySet()
- saveHiddenIds(emptySet())
- rebuild()
- }
}
fun isCustomStation(id: String): Boolean {
@@ -161,7 +125,7 @@ class StationRepository @Inject constructor(
private fun rebuild() {
stations.clear()
- stations.addAll(DefaultStations.getAll().filter { it.id !in _hiddenIds.value })
+ stations.addAll(DefaultStations.getAll())
stations.addAll(loadCustomStations())
for (i in stations.indices) {
val station = stations[i]
@@ -171,7 +135,8 @@ class StationRepository @Inject constructor(
}
}
- private fun loadNameOverrides(): Map { val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap()
+ private fun loadNameOverrides(): Map {
+ val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap()
return try {
val obj = JSONObject(json)
obj.keys().asSequence().associateWith { key -> obj.getString(key) }
@@ -204,7 +169,8 @@ class StationRepository @Inject constructor(
}
}
- private fun saveCustomStations() { val custom = stations.filter { isCustomStation(it.id) }
+ private fun saveCustomStations() {
+ val custom = stations.filter { isCustomStation(it.id) }
val arr = JSONArray()
custom.forEach { s ->
arr.put(JSONObject().apply {
@@ -217,26 +183,9 @@ class StationRepository @Inject constructor(
prefs.edit().putString(KEY_CUSTOM_STATIONS, arr.toString()).apply()
}
- private fun loadHiddenIds(): Set {
- val json = prefs.getString(KEY_HIDDEN_STATIONS, null) ?: return emptySet()
- return try {
- val arr = JSONArray(json)
- (0 until arr.length()).mapTo(mutableSetOf()) { i -> arr.getString(i) }
- } catch (_: Exception) {
- emptySet()
- }
- }
-
- private fun saveHiddenIds(ids: Set) {
- val arr = JSONArray()
- ids.forEach { arr.put(it) }
- prefs.edit().putString(KEY_HIDDEN_STATIONS, arr.toString()).apply()
- }
-
companion object {
private const val KEY_CUSTOM_STATIONS = "custom_stations"
private const val KEY_NAME_OVERRIDES = "station_name_overrides"
private const val KEY_FAVORITE_STATIONS = "favorite_stations"
- private const val KEY_HIDDEN_STATIONS = "hidden_stations"
}
}
diff --git a/app/src/main/java/com/rada/data/repository/StreamRepository.kt b/app/src/main/java/com/rada/data/repository/StreamRepository.kt
index da80e12..8e53b42 100644
--- a/app/src/main/java/com/rada/data/repository/StreamRepository.kt
+++ b/app/src/main/java/com/rada/data/repository/StreamRepository.kt
@@ -21,34 +21,6 @@ class StreamRepository @Inject constructor(
private var cache: List? = null
- init {
- ensureBuiltins()
- }
-
- /**
- * Media Monarchy ships as a built-in stream (talk, not music — it lives
- * here instead of the Stations tab). Seeded exactly once per install so
- * a user delete is respected and never resurrected.
- */
- private fun ensureBuiltins() {
- if (prefs.getBoolean(KEY_BUILTINS_SEEDED, false)) return
- val current = getAll().toMutableList()
- if (current.none { it.url == BUILTIN_MONARCHY_URL }) {
- current.add(
- 0,
- StreamEntry(
- id = "media-monarchy",
- name = "Media Monarchy",
- url = BUILTIN_MONARCHY_URL,
- source = "Built-in",
- group = "Talk",
- ),
- )
- save(current)
- }
- prefs.edit().putBoolean(KEY_BUILTINS_SEEDED, true).apply()
- }
-
fun getAll(): List {
cache?.let { return it }
val raw = prefs.getString(key, "[]") ?: "[]"
@@ -89,9 +61,4 @@ class StreamRepository @Inject constructor(
cache = list
prefs.edit().putString(key, json.encodeToString(streamEntriesSerializer, list)).apply()
}
-
- companion object {
- private const val KEY_BUILTINS_SEEDED = "builtins_seeded"
- private const val BUILTIN_MONARCHY_URL = "http://216.158.245.68:8000/listen"
- }
}
diff --git a/app/src/main/java/com/rada/data/wavlake/StationFeature.kt b/app/src/main/java/com/rada/data/wavlake/StationFeature.kt
deleted file mode 100644
index d6a6b42..0000000
--- a/app/src/main/java/com/rada/data/wavlake/StationFeature.kt
+++ /dev/null
@@ -1,112 +0,0 @@
-package com.rada.data.wavlake
-
-import com.rada.domain.model.WavlakeTrack
-import kotlinx.serialization.Serializable
-import kotlin.random.Random
-
-/**
- * Optional, configurable featuring of Wavlake tracks alongside (never
- * inside) live radio stations. Stations are live streams, so featuring is
- * an opt-in shelf the user taps — it never replaces station content,
- * never interrupts playback, and any API failure degrades to "no picks".
- */
-@Serializable
-data class WavlakeStationSettings(
- val enabled: Boolean = true,
- /** Probability a station view refreshes its picks (0.0–1.0). */
- val featureProbability: Double = 0.1,
- val maxFeaturedTracksPerHour: Int = 3,
- val maxFeaturedTracksPerQueue: Int = 1,
- val matchingMode: MatchingMode = MatchingMode.EXACT_OR_RELATED,
-)
-
-enum class MatchingMode { EXACT, RELATED, EXACT_OR_RELATED }
-
-val defaultWavlakeStationSettings = WavlakeStationSettings()
-
-/**
- * Station genre -> exact Wavlake catalog genre names
- * (https://github.com/wavlake/genre-list). Only names from that list are
- * ever sent to the API — unknown names return HTTP 400 there.
- */
-object WavlakeGenreMatcher {
- private val related: Map> = mapOf(
- "Lofi" to listOf("Downtempo", "Ambient", "Electronic"),
- "Synthwave" to listOf("Electro", "Electronica", "Electronic", "Electronic Rock"),
- "EDM" to listOf(
- "Dance/EDM", "House", "Jackin House", "Dubstep", "Techno", "Trance",
- "Breakbeat", "Garage", "Hard Dance", "Jungle/Drum'n'bass", "Hi-NRG / Eurodance",
- ),
- "NCS" to listOf(
- "Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "House", "Dubstep",
- "Jungle/Drum'n'bass", "Trance", "Techno",
- ),
- "Electronic" to listOf("Electronic", "Electronica", "Downtempo", "Ambient"),
- "Hip-Hop" to listOf("Hip-Hop/Rap", "Hip-Hop", "Rap"),
- "Pop" to listOf("Pop", "Indie Pop"),
- "Rock" to listOf("Rock", "Alternative Rock", "Indie Rock"),
- )
-
- /** Station "A / B" strings use the same split convention as the app. */
- fun splitStationGenres(genre: String): Pair> {
- val parts = genre.split(" / ").map { it.trim() }.filter { it.isNotEmpty() }
- return (parts.firstOrNull() ?: "") to parts.drop(1)
- }
-
- /** Exact Wavlake genre names compatible with a station, per [MatchingMode]. */
- fun compatibleGenres(
- primary: String,
- secondary: List = emptyList(),
- mode: MatchingMode = MatchingMode.EXACT_OR_RELATED,
- ): List {
- val out = LinkedHashSet()
- val keys = (listOf(primary) + secondary).map { it.trim() }.filter { it.isNotEmpty() }
- if (mode == MatchingMode.EXACT || mode == MatchingMode.EXACT_OR_RELATED) {
- keys.filter { isWavlakeGenreName(it) }.forEach { out.add(it) }
- }
- if (mode == MatchingMode.RELATED || mode == MatchingMode.EXACT_OR_RELATED) {
- keys.forEach { g -> related[g]?.let { out.addAll(it) } }
- }
- return out.toList()
- }
-
- private fun isWavlakeGenreName(name: String): Boolean =
- related.values.any { it.contains(name) }
-}
-
-/** Inputs for one featuring decision. All IDs are Wavlake track/artist IDs. */
-data class FeatureSelection(
- val candidates: List,
- val settings: WavlakeStationSettings = defaultWavlakeStationSettings,
- /** Track IDs already queued or playing — never duplicated. */
- val excludedTrackIds: Set = emptySet(),
- /** Artist IDs blocked by the user. */
- val blockedArtistIds: Set = emptySet(),
- /** Artist IDs featured recently — deprioritized to keep rotation fresh. */
- val recentArtistIds: Set = emptySet(),
- /** How many tracks were featured in the last hour (cap gate). */
- val featuredThisHour: Int = 0,
- val random: Random = Random.Default,
-)
-
-object FeatureSelector {
- fun select(input: FeatureSelection): List {
- val s = input.settings
- if (!s.enabled) return emptyList()
- if (input.featuredThisHour >= s.maxFeaturedTracksPerHour) return emptyList()
- if (s.maxFeaturedTracksPerQueue <= 0) return emptyList()
- if (input.random.nextDouble() >= s.featureProbability.coerceIn(0.0, 1.0)) return emptyList()
-
- val pool = input.candidates
- .filter { it.audioUrl?.isNotBlank() == true }
- .filterNot { it.id in input.excludedTrackIds }
- .filterNot { it.artistId in input.blockedArtistIds }
- .distinctBy { it.id }
- if (pool.isEmpty()) return emptyList()
-
- // Fresh artists first, stable order otherwise; then take the cap.
- val fresh = pool.filterNot { it.artistId in input.recentArtistIds }
- val ordered = (fresh.ifEmpty { pool }).shuffled(input.random)
- return ordered.take(s.maxFeaturedTracksPerQueue)
- }
-}
diff --git a/app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt b/app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt
deleted file mode 100644
index c4cc385..0000000
--- a/app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt
+++ /dev/null
@@ -1,227 +0,0 @@
-package com.rada.data.wavlake
-
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.Json
-import okhttp3.HttpUrl.Companion.toHttpUrl
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import java.util.concurrent.TimeUnit
-
-/**
- * Raw DTOs mirror wavlake/developer-docs swagger.yaml exactly (all fields
- * optional-tolerant; unknown keys ignored for forward compatibility).
- * Base https://wavlake.com/api/v1. No authentication (the spec's
- * securityDefinitions are commented out; /lnurl needs a Wavlake-issued
- * appId and is intentionally NOT used — zaps go through the app's
- * existing NIP-57 wallet flow).
- */
-
-// ── DTOs ─────────────────────────────────────────────────────────────────
-
-@Serializable
-data class SearchResultDto(
- val id: String? = null,
- val name: String? = null,
- val type: String? = null, // artist | album | track
- val albumArtUrl: String? = null,
- val artistArtUrl: String? = null,
- val albumId: String? = null,
- val albumTitle: String? = null,
- val artistId: String? = null,
- val artist: String? = null,
- val duration: Int? = null,
-)
-
-@Serializable
-data class TrackDto(
- val id: String? = null,
- val title: String? = null,
- val albumTitle: String? = null,
- val artist: String? = null,
- val artistId: String? = null,
- val albumId: String? = null,
- val artistArtUrl: String? = null,
- val albumArtUrl: String? = null,
- val mediaUrl: String? = null,
- val duration: Int? = null,
- val releaseDate: String? = null,
- val msatTotal: String? = null,
- val artistNpub: String? = null,
- val genre: String? = null,
- val url: String? = null,
-)
-
-@Serializable
-data class CatalogDto(
- val tracks: List = emptyList(),
- val nextCursor: String? = null,
- val total: Int? = null,
- val limit: Int? = null,
-)
-
-@Serializable
-data class AlbumSummaryDto(
- val id: String? = null,
- val title: String? = null,
- val albumArtUrl: String? = null,
- val releaseDate: String? = null,
-)
-
-@Serializable
-data class ArtistDto(
- val id: String? = null,
- val name: String? = null,
- val bio: String? = null,
- val artistArtUrl: String? = null,
- val albums: List = emptyList(),
-)
-
-@Serializable
-data class AlbumDto(
- val id: String? = null,
- val title: String? = null,
- val artist: String? = null,
- val artistUrl: String? = null,
- val artistArtUrl: String? = null,
- val albumArtUrl: String? = null,
- val releaseDate: String? = null,
- val tracks: List = emptyList(),
-)
-
-@Serializable
-data class ErrorDto(val error: String? = null)
-
-// ── Result ───────────────────────────────────────────────────────────────
-
-sealed interface WavlakeResult {
- data class Ok(val value: T) : WavlakeResult
- /** API failure never throws — local radio keeps playing regardless. */
- data class Failed(val kind: FailureKind, val message: String) : WavlakeResult
-}
-
-enum class FailureKind { NETWORK, HTTP, RATE_LIMITED, PARSE, EMPTY, AUTH }
-
-// ── Client ───────────────────────────────────────────────────────────────
-
-class WavlakeApi(
- private val baseUrl: String = "https://wavlake.com/api/v1",
- private val client: OkHttpClient = defaultClient(),
-) {
- private val json = Json {
- ignoreUnknownKeys = true
- explicitNulls = false
- coerceInputValues = true
- }
-
- suspend fun search(term: String): WavlakeResult> =
- get("content/search", mapOf("term" to term)) { body ->
- json.decodeFromString(
- kotlinx.serialization.builtins.ListSerializer(SearchResultDto.serializer()),
- body,
- )
- }
-
- suspend fun rankings(
- days: Int = 7,
- genre: String? = null,
- limit: Int = 25,
- ): WavlakeResult> {
- val params = mutableMapOf("sort" to "sats", "days" to days.toString(), "limit" to limit.toString())
- if (!genre.isNullOrBlank()) params["genre"] = genre
- return get("content/rankings", params) { body ->
- json.decodeFromString(
- kotlinx.serialization.builtins.ListSerializer(TrackDto.serializer()),
- body,
- )
- }
- }
-
- suspend fun catalog(
- limit: Int = 50,
- cursor: String? = null,
- artistId: String? = null,
- genre: String? = null,
- ): WavlakeResult {
- val params = mutableMapOf("limit" to limit.coerceIn(1, 100).toString())
- if (!cursor.isNullOrBlank()) params["cursor"] = cursor
- if (!artistId.isNullOrBlank()) params["artistId"] = artistId
- if (!genre.isNullOrBlank()) params["genre"] = genre
- return get("content/tracks", params) { body ->
- json.decodeFromString(CatalogDto.serializer(), body)
- }
- }
-
- suspend fun track(trackId: String): WavlakeResult =
- get("content/track/$trackId", emptyMap()) { body ->
- json.decodeFromString(TrackDto.serializer(), body)
- }
-
- suspend fun artist(artistId: String): WavlakeResult =
- get("content/artist/$artistId", emptyMap()) { body ->
- json.decodeFromString(ArtistDto.serializer(), body)
- }
-
- suspend fun album(albumId: String): WavlakeResult =
- get("content/album/$albumId", emptyMap()) { body ->
- json.decodeFromString(AlbumDto.serializer(), body)
- }
-
- private suspend fun get(
- path: String,
- params: Map,
- parse: (String) -> T,
- ): WavlakeResult = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
- val url = try {
- (baseUrl.trimEnd('/') + "/" + path).toHttpUrl().newBuilder()
- .also { b -> params.forEach { (k, v) -> b.addQueryParameter(k, v) } }
- .build()
- } catch (e: Exception) {
- return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Bad request URL.")
- }
- val response = try {
- client.newCall(
- Request.Builder()
- .url(url)
- .header("Accept", "application/json")
- .header("User-Agent", "SatsAmp/0.1 (wavlake catalog client)")
- .build(),
- ).execute()
- } catch (e: Exception) {
- // Offline, DNS, TLS, timeouts — all surface here, never thrown.
- return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Network unavailable.")
- }
- response.use { r ->
- if (r.code == 429) {
- val retryAfter = r.header("Retry-After")?.toLongOrNull()
- return@withContext WavlakeResult.Failed(
- FailureKind.RATE_LIMITED,
- "Rate limited" + (retryAfter?.let { " — retry in ${it}s" } ?: "."),
- )
- }
- if (!r.isSuccessful) {
- val serverMsg = runCatching {
- json.decodeFromString(ErrorDto.serializer(), r.body?.string().orEmpty()).error
- }.getOrNull()
- val kind = if (r.code == 401 || r.code == 403) FailureKind.AUTH else FailureKind.HTTP
- return@withContext WavlakeResult.Failed(kind, serverMsg ?: "Request failed (${r.code}).")
- }
- val body = try {
- r.body?.string().orEmpty()
- } catch (_: Exception) {
- return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Empty response.")
- }
- try {
- WavlakeResult.Ok(parse(body))
- } catch (_: Exception) {
- WavlakeResult.Failed(FailureKind.PARSE, "Couldn't read the catalog response.")
- }
- }
- }
-
- companion object {
- fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
- .connectTimeout(15, TimeUnit.SECONDS)
- .readTimeout(20, TimeUnit.SECONDS)
- .build()
- }
-}
diff --git a/app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt b/app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt
deleted file mode 100644
index da4e33b..0000000
--- a/app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt
+++ /dev/null
@@ -1,441 +0,0 @@
-package com.rada.data.wavlake
-
-import android.content.Context
-import com.rada.data.local.entity.SavedSongEntity
-import com.rada.data.repository.LibraryRepository
-import com.rada.domain.model.SOURCE_WAVLAKE
-import com.rada.domain.model.WavlakeArtist
-import com.rada.domain.model.WavlakeTrack
-import com.rada.nostr.Nip19
-import com.rada.nostr.NostrService
-import com.rada.payments.hasValidLightningAddress
-import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.flow.firstOrNull
-import kotlinx.coroutines.withTimeoutOrNull
-import kotlinx.serialization.builtins.ListSerializer
-import kotlinx.serialization.builtins.MapSerializer
-import kotlinx.serialization.builtins.serializer
-import kotlinx.serialization.json.Json
-import org.json.JSONObject
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * Wavlake catalog gateway (official public API, no auth — see [WavlakeApi]).
- *
- * Hard guarantees:
- * - Every function returns normally; API trouble surfaces as
- * [WavlakeResult.Failed] or empty lists — local radio never stops.
- * - No secrets: nothing here needs credentials, so no .env is required.
- * - The catalog exposes NO Lightning Address (only artist npub). Addresses
- * resolve via the artist's Nostr kind-0 profile (lud16) and are validated
- * with [hasValidLightningAddress] — the ONLY zap gate.
- */
-@Singleton
-class WavlakeRepository @Inject constructor(
- @ApplicationContext private val context: Context,
- private val api: WavlakeApi,
- private val nostr: NostrService,
- private val library: LibraryRepository,
-) {
- private val prefs by lazy {
- context.getSharedPreferences("wavlake", Context.MODE_PRIVATE)
- }
- private val json = Json { ignoreUnknownKeys = true }
-
- // ── Normalization (pure top-level fns, JVM-testable) ─────────────────
-
- suspend fun search(term: String): WavlakeResult> {
- if (term.isBlank()) return WavlakeResult.Ok(emptyList())
- return when (val r = api.search(term.trim())) {
- is WavlakeResult.Ok -> WavlakeResult.Ok(
- r.value.mapNotNull { dto ->
- val id = dto.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
- val name = dto.name?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
- when (dto.type) {
- "artist" -> WavlakeSearchHit.Artist(
- WavlakeArtist(
- id = id, name = name,
- imageUrl = dto.artistArtUrl ?: dto.albumArtUrl,
- ),
- )
- "album" -> WavlakeSearchHit.Album(
- id = id, title = name,
- artist = dto.artist,
- artworkUrl = dto.albumArtUrl,
- )
- else -> WavlakeSearchHit.Track(
- WavlakeTrack(
- id = id, title = name,
- artistId = dto.artistId.orEmpty(),
- artistName = dto.artist?.takeIf { it.isNotBlank() }
- ?: "Unknown Artist",
- artworkUrl = dto.albumArtUrl ?: dto.artistArtUrl,
- durationMs = dto.duration?.let { if (it > 0) it * 1000L else null },
- ),
- )
- }
- }.distinctBy { it.stableId() },
- )
- is WavlakeResult.Failed -> r
- }
- }
-
- suspend fun rankings(
- days: Int = 7,
- genre: String? = null,
- limit: Int = 25,
- ): WavlakeResult> =
- when (val r = api.rankings(days.coerceIn(1, 90), genre, limit.coerceIn(1, 100))) {
- is WavlakeResult.Ok -> WavlakeResult.Ok(
- r.value.mapNotNull { normalizeWavlakeTrack(it) }.distinctBy { it.id },
- )
- is WavlakeResult.Failed -> r
- }
-
- suspend fun artistDetail(artistId: String): WavlakeResult {
- val artist = when (val r = api.artist(artistId)) {
- is WavlakeResult.Ok -> normalizeWavlakeArtist(r.value)
- ?: return WavlakeResult.Failed(FailureKind.EMPTY, "Artist has no usable metadata.")
- is WavlakeResult.Failed -> return r
- }
- // The artist endpoint carries no genre; derive it from the tracks.
- val tracks = when (val r = api.catalog(limit = 100, artistId = artistId)) {
- is WavlakeResult.Ok -> r.value.tracks.mapNotNull { normalizeWavlakeTrack(it) }
- is WavlakeResult.Failed -> emptyList() // tracks optional; artist still shown
- }.distinctBy { it.id }
- val genres = tracks.mapNotNull { it.genre }.distinct()
- return WavlakeResult.Ok(
- WavlakeArtistDetail(
- artist = artist.copy(
- genre = genres.firstOrNull(),
- genres = genres,
- imageUrl = artist.imageUrl
- ?: tracks.firstNotNullOfOrNull { it.artworkUrl },
- ),
- tracks = tracks,
- ),
- )
- }
-
- suspend fun albumTracks(albumId: String): WavlakeResult> =
- when (val r = api.album(albumId)) {
- is WavlakeResult.Ok -> WavlakeResult.Ok(
- r.value.tracks.mapNotNull { normalizeWavlakeTrack(it) }.distinctBy { it.id },
- )
- is WavlakeResult.Failed -> r
- }
-
- suspend fun trackDetail(trackId: String): WavlakeResult =
- when (val r = api.track(trackId)) {
- is WavlakeResult.Ok ->
- normalizeWavlakeTrack(r.value)?.let { WavlakeResult.Ok(it) }
- ?: WavlakeResult.Failed(FailureKind.EMPTY, "Track has no usable metadata.")
- is WavlakeResult.Failed -> r
- }
-
- /**
- * Fallback for search-index IDs that no longer resolve (stale entries
- * 400 on /content/track): match by exact title inside the artist's
- * catalog page. Same artist + same title = the same song.
- */
- suspend fun matchArtistTrack(artistId: String, title: String): WavlakeTrack? {
- if (artistId.isBlank() || title.isBlank()) return null
- return when (val r = api.catalog(limit = 100, artistId = artistId)) {
- is WavlakeResult.Ok -> r.value.tracks
- .mapNotNull { normalizeWavlakeTrack(it) }
- .firstOrNull {
- it.title.equals(title, ignoreCase = true) &&
- it.audioUrl?.isNotBlank() == true
- }
- is WavlakeResult.Failed -> null
- }
- }
-
- // ── Lightning Address (Nostr kind-0 lud16 — the ONLY zap source) ─────
-
- private val addressCache = java.util.concurrent.ConcurrentHashMap()
-
- /**
- * Addresses the user entered themselves (stored on successful zap).
- * These are real, validated addresses — remembering them is what lets
- * the bolt appear for artists the catalog exposes no npub for.
- */
- fun getKnownAddress(artistId: String): String? {
- val raw = prefs.getString(KEY_KNOWN_PREFIX + artistId, null) ?: return null
- return raw.takeIf { hasValidLightningAddress(it) }
- }
-
- fun rememberAddress(artistId: String, address: String): Boolean {
- if (artistId.isBlank()) return false
- val clean = address.trim()
- if (!hasValidLightningAddress(clean)) return false
- prefs.edit().putString(KEY_KNOWN_PREFIX + artistId, clean).apply()
- return true
- }
-
- /** Best-effort lud16 for an artist npub (bech32 or hex). Null when unknown. */
- suspend fun resolveLightningAddress(artistId: String?, artistNpub: String?): String? {
- if (!artistId.isNullOrBlank()) {
- getKnownAddress(artistId)?.let { return it }
- }
- val npub = artistNpub?.takeIf { it.isNotBlank() } ?: return null
- addressCache[npub]?.let { return it }
- val hex = npubToHex(npub) ?: return null
- // Mobile relays can be slow; one retry on miss. Misses only log —
- // the strict gate simply shows no bolt.
- var lud16: String? = null
- repeat(2) {
- lud16 = withTimeoutOrNull(12_000) {
- nostr.getProfile(hex).firstOrNull { it != null }?.let { ev ->
- runCatching {
- JSONObject(ev!!.content).optString("lud16").takeIf { it.isNotBlank() }
- }.getOrNull()
- }
- }
- if (lud16 != null) return@repeat
- }
- val valid = lud16?.takeIf { hasValidLightningAddress(it) }
- if (valid == null) {
- android.util.Log.w("Wavlake", "No lud16 for artist npub ${hex.take(12)}…")
- } else {
- addressCache[npub] = valid
- }
- return valid
- }
-
- private fun npubToHex(npub: String): String? {
- val v = npub.trim()
- if (v.startsWith("npub1")) {
- return runCatching {
- val (hrp, bytes) = Nip19.decode(v) ?: return null
- if (hrp != "npub" || bytes.size != 32) return null
- bytes.joinToString("") { "%02x".format(it) }
- }.getOrNull()
- }
- if (v.length == 64 && v.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) return v.lowercase()
- return null
- }
-
- // ── Import (streamable library entries — no downloads) ───────────────
-
- sealed interface ImportOutcome {
- data class Imported(val songId: Long) : ImportOutcome
- data class AlreadyInLibrary(val songId: Long) : ImportOutcome
- data class Unplayable(val reason: String) : ImportOutcome
- }
-
- /** Imports a Wavlake track as a streamable library entry (dedupe-safe). */
- suspend fun importTrack(track: WavlakeTrack): ImportOutcome {
- val audioUrl = track.audioUrl?.takeIf { it.isNotBlank() }
- ?: return ImportOutcome.Unplayable("No playable audio for this track.")
- library.getByPath(audioUrl)?.let { return ImportOutcome.AlreadyInLibrary(it.id) }
- library.getByArtistTitle(track.artistName, track.title)?.let {
- return ImportOutcome.AlreadyInLibrary(it.id)
- }
- val id = library.insert(
- SavedSongEntity(
- fileName = "${track.artistName} - ${track.title}.mp3",
- filePath = audioUrl,
- artist = track.artistName,
- title = track.title,
- album = track.genre ?: "Wavlake",
- stationName = "Wavlake",
- durationMs = track.durationMs ?: 0,
- fileSize = 0,
- format = "mp3",
- source = SOURCE_WAVLAKE,
- sourceUrl = track.sourceUrl.orEmpty(),
- artworkUrl = track.artworkUrl.orEmpty(),
- wavlakeArtistId = track.artistId,
- wavlakeArtistNpub = track.artistNpubHex.orEmpty(),
- ),
- )
- return ImportOutcome.Imported(id)
- }
-
- // ── Featuring (genre-matched, opt-in shelf — never auto-inserted) ────
-
- fun settings(): WavlakeStationSettings = runCatching {
- prefs.getString(KEY_SETTINGS, null)?.let {
- Json.decodeFromString(WavlakeStationSettings.serializer(), it)
- }
- }.getOrNull() ?: defaultWavlakeStationSettings
-
- fun saveSettings(settings: WavlakeStationSettings) {
- prefs.edit()
- .putString(KEY_SETTINGS, Json.encodeToString(WavlakeStationSettings.serializer(), settings))
- .apply()
- }
-
- fun isStationEnabled(stationId: String): Boolean {
- if (!settings().enabled) return false
- val map = stationMap()
- return map[stationId] ?: true
- }
-
- fun setStationEnabled(stationId: String, enabled: Boolean) {
- val map = stationMap().toMutableMap()
- map[stationId] = enabled
- prefs.edit().putString(KEY_STATION_MAP, Json.encodeToString(mapSerializer(), map)).apply()
- }
-
- fun blockedArtistIds(): Set = runCatching {
- prefs.getString(KEY_BLOCKED, null)?.let {
- Json.decodeFromString(ListSerializer(String.serializer()), it)
- }.orEmpty().toSet()
- }.getOrDefault(emptySet())
-
- fun setArtistBlocked(artistId: String, blocked: Boolean) {
- val set = blockedArtistIds().toMutableSet()
- if (blocked) set.add(artistId) else set.remove(artistId)
- prefs.edit()
- .putString(KEY_BLOCKED, Json.encodeToString(ListSerializer(String.serializer()), set.toList()))
- .apply()
- }
-
- private fun stationMap(): Map = runCatching {
- prefs.getString(KEY_STATION_MAP, null)?.let {
- Json.decodeFromString(mapSerializer(), it)
- }
- }.getOrNull().orEmpty()
-
- private fun mapSerializer() =
- MapSerializer(String.serializer(), Boolean.serializer())
-
- private data class FeatureEvent(val atMs: Long, val artistId: String)
-
- private fun recentEvents(): List {
- val now = System.currentTimeMillis()
- return runCatching {
- prefs.getString(KEY_FEATURED, null)?.let { raw ->
- Json.decodeFromString(
- ListSerializer(FeatureEventSer.serializer()),
- raw,
- ).map { FeatureEvent(it.atMs, it.artistId) }
- }
- }.getOrNull().orEmpty().filter { now - it.atMs < 3_600_000L }
- }
-
- @kotlinx.serialization.Serializable
- private data class FeatureEventSer(val atMs: Long, val artistId: String)
-
- private fun recordFeatured(artistIds: List) {
- val now = System.currentTimeMillis()
- val updated = (recentEvents() + artistIds.map { FeatureEvent(now, it) })
- .filter { now - it.atMs < 3_600_000L }
- prefs.edit().putString(
- KEY_FEATURED,
- Json.encodeToString(
- ListSerializer(FeatureEventSer.serializer()),
- updated.map { FeatureEventSer(it.atMs, it.artistId) },
- ),
- ).apply()
- }
-
- /**
- * Genre-matched Wavlake picks for a station. Network-bound, failure-safe
- * (empty on any trouble), and purely advisory — the caller decides
- * whether to show them; nothing here touches playback or the queue.
- */
- suspend fun featuredForStation(
- stationId: String,
- stationGenre: String,
- excludedTrackIds: Set = emptySet(),
- ): List = coroutineScope {
- val settings = settings()
- if (!isStationEnabled(stationId)) return@coroutineScope emptyList()
- val (primary, secondary) = WavlakeGenreMatcher.splitStationGenres(stationGenre)
- val genres = WavlakeGenreMatcher.compatibleGenres(primary, secondary, settings.matchingMode)
- if (genres.isEmpty()) return@coroutineScope emptyList()
-
- val events = recentEvents()
- val perGenre = genres.take(3).map { genre ->
- async {
- when (val r = api.rankings(days = 30, genre = genre, limit = 25)) {
- is WavlakeResult.Ok -> r.value.mapNotNull { normalizeWavlakeTrack(it) }
- is WavlakeResult.Failed -> emptyList()
- }
- }
- }.awaitAll().flatten().distinctBy { it.id }
-
- val picked = FeatureSelector.select(
- FeatureSelection(
- candidates = perGenre,
- settings = settings,
- excludedTrackIds = excludedTrackIds,
- blockedArtistIds = blockedArtistIds(),
- recentArtistIds = events.map { it.artistId }.toSet(),
- featuredThisHour = events.size,
- ),
- )
- if (picked.isNotEmpty()) recordFeatured(picked.map { it.artistId })
- picked
- }
-
- companion object {
- private const val KEY_SETTINGS = "wavlake_settings"
- private const val KEY_STATION_MAP = "wavlake_station_enabled"
- private const val KEY_BLOCKED = "wavlake_blocked_artists"
- private const val KEY_FEATURED = "wavlake_featured_hour"
- private const val KEY_KNOWN_PREFIX = "wavlake_addr_"
- }
-}
-
-sealed interface WavlakeSearchHit { fun stableId(): String
- data class Artist(val artist: WavlakeArtist) : WavlakeSearchHit {
- override fun stableId() = "artist:${artist.id}"
- }
- data class Album(
- val id: String,
- val title: String,
- val artist: String?,
- val artworkUrl: String?,
- ) : WavlakeSearchHit {
- override fun stableId() = "album:$id"
- }
- data class Track(val track: WavlakeTrack) : WavlakeSearchHit {
- override fun stableId() = "track:${track.id}"
- }
-}
-
-data class WavlakeArtistDetail(
- val artist: WavlakeArtist,
- val tracks: List,
-)
-
-/** Pure DTO normalization (null-tolerant, JVM-testable). */
-fun normalizeWavlakeTrack(dto: TrackDto): WavlakeTrack? {
- val id = dto.id?.takeIf { it.isNotBlank() } ?: return null
- val title = dto.title?.takeIf { it.isNotBlank() } ?: return null
- val audioUrl = dto.mediaUrl?.takeIf { it.isNotBlank() }
- return WavlakeTrack(
- id = id,
- title = title,
- artistId = dto.artistId?.takeIf { it.isNotBlank() }.orEmpty(),
- artistName = dto.artist?.takeIf { it.isNotBlank() } ?: "Unknown Artist",
- genre = dto.genre?.takeIf { it.isNotBlank() },
- genres = listOfNotNull(dto.genre?.takeIf { it.isNotBlank() }),
- artworkUrl = dto.albumArtUrl?.takeIf { it.isNotBlank() }
- ?: dto.artistArtUrl?.takeIf { it.isNotBlank() },
- durationMs = dto.duration?.let { if (it > 0) it * 1000L else null },
- audioUrl = audioUrl,
- sourceUrl = dto.url?.takeIf { it.isNotBlank() },
- artistNpubHex = dto.artistNpub?.takeIf { it.isNotBlank() },
- albumId = dto.albumId?.takeIf { it.isNotBlank() },
- )
-}
-
-/** Pure DTO normalization (null-tolerant, JVM-testable). */
-fun normalizeWavlakeArtist(dto: ArtistDto): WavlakeArtist? {
- val id = dto.id?.takeIf { it.isNotBlank() } ?: return null
- return WavlakeArtist(
- id = id,
- name = dto.name?.takeIf { it.isNotBlank() } ?: "Unknown Artist",
- imageUrl = dto.artistArtUrl?.takeIf { it.isNotBlank() },
- bio = dto.bio?.takeIf { it.isNotBlank() },
- )
-}
diff --git a/app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt b/app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt
deleted file mode 100644
index 89e5578..0000000
--- a/app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt
+++ /dev/null
@@ -1,104 +0,0 @@
-package com.rada.data.wavlake
-
-import com.rada.nostr.MusicKinds
-import com.rada.nostr.NostrEvent
-import com.rada.nostr.UnsignedEvent
-import java.security.MessageDigest
-import java.util.UUID
-
-/**
- * Wavlake's official track-zap protocol, decoded from their MIT-licensed
- * mobile client (wavlake/mobile: useZapContent + utils/nostr.ts — patterns
- * only, no copied code):
- *
- * - Recipient is the PLATFORM key ([WAVLAKE_FEED_PUBKEY]); Wavlake splits
- * downstream. No artist address or npub is required.
- * - The NIP-57 zap request carries an `a` tag with the track's Nostr
- * address (`32123::`), a `timestamp` tag, and three
- * podcast `i` tags whose GUIDs are UUIDv5 (RFC 4122) of the feed URLs.
- * - `GET https://www.wavlake.com/api/zap?amount={msats}&nostr={eventJson}`
- * returns LUD-06-style `{pr}` (or `{status:"ERROR"}` / `{success:false}`).
- *
- * This is what makes every catalog track payable even when the artist
- * exposes no Lightning Address anywhere reachable.
- */
-object WavlakeZap {
- const val FEED_PUBKEY =
- "7759fb24cec56fc57550754ca8f6d2c60183da2537c8f38108fdf283b20a0e58"
- const val TRACK_KIND = 32123
- const val RELAY = "wss://relay.wavlake.com/"
- const val ZAP_ENDPOINT = "https://www.wavlake.com/api/zap"
- const val ZAP_HOST = "www.wavlake.com"
-
- /** UUID namespace from the official client (podcast feed GUIDs). */
- val FEED_NAMESPACE: UUID = UUID.fromString("ead4c236-bf58-58c6-a2c6-a6b28d128cb6")
-
- fun feedUrl(albumId: String): String = "wavlake.com/feed/music/$albumId"
-
- /** RFC 4122 §4.3 UUIDv5 (SHA-1). Java only ships v3/v4. */
- fun uuidV5(namespace: UUID, name: String): UUID {
- val md = MessageDigest.getInstance("SHA-1")
- val ns = ByteArray(16).also {
- val bb = java.nio.ByteBuffer.wrap(it)
- bb.putLong(namespace.mostSignificantBits)
- bb.putLong(namespace.leastSignificantBits)
- }
- val hash = md.digest(ns + name.toByteArray(Charsets.UTF_8))
- hash[6] = ((hash[6].toInt() and 0x0f) or 0x50).toByte() // version 5
- hash[8] = ((hash[8].toInt() and 0x3f) or 0x80).toByte() // variant 10
- val bb = java.nio.ByteBuffer.wrap(hash)
- return UUID(bb.long, bb.long)
- }
-
- fun trackAddressPointer(trackId: String): String = "$TRACK_KIND:$FEED_PUBKEY:$trackId"
-
- fun itemTags(trackId: String, albumId: String): List> {
- val feedGuid = uuidV5(FEED_NAMESPACE, feedUrl(albumId)).toString()
- return listOf(
- listOf("i", "podcast:item:guid:$trackId"),
- listOf("i", "podcast:guid:$feedGuid"),
- listOf("i", "podcast:publisher:guid:$feedGuid"),
- )
- }
-
- /**
- * Unsigned NIP-57 zap request for a track. The caller signs (identity
- * key preferred, ephemeral per NIP-57 otherwise) — see ZapService.
- */
- fun buildZapRequest(
- trackId: String,
- albumId: String,
- amountSats: Long,
- comment: String,
- relays: List,
- signerPubkeyHex: String,
- ): UnsignedEvent {
- require(trackId.isNotBlank() && albumId.isNotBlank())
- require(amountSats > 0)
- val tags = mutableListOf(
- listOf("p", FEED_PUBKEY),
- listOf("relays", RELAY) + relays.take(2),
- listOf("amount", (amountSats * 1000).toString()),
- listOf("a", trackAddressPointer(trackId), RELAY),
- listOf("timestamp", (System.currentTimeMillis() / 1000).toString()),
- )
- tags.addAll(itemTags(trackId, albumId))
- return NostrEvent.unsigned(
- pubkey = signerPubkeyHex,
- kind = MusicKinds.ZAP_REQUEST,
- content = comment,
- tags = tags,
- )
- }
-
- fun invoiceUrl(amountSats: Long, zapRequestJson: String): String {
- val encoded = java.net.URLEncoder.encode(zapRequestJson, "UTF-8")
- return "$ZAP_ENDPOINT?amount=${amountSats * 1000}&nostr=$encoded"
- }
-
- /** Strict invoice sanity: mainnet bolt11 only, from the fixed endpoint. */
- fun isPlausibleInvoice(bolt11: String): Boolean {
- val v = bolt11.trim().lowercase()
- return v.startsWith("lnbc") && v.length > 20 && v.none { it.isWhitespace() }
- }
-}
diff --git a/app/src/main/java/com/rada/di/AppModule.kt b/app/src/main/java/com/rada/di/AppModule.kt
index 5d961aa..7de48e5 100644
--- a/app/src/main/java/com/rada/di/AppModule.kt
+++ b/app/src/main/java/com/rada/di/AppModule.kt
@@ -7,7 +7,6 @@ import com.rada.data.local.dao.SavedSongDao
import com.rada.data.repository.LibraryRepository
import com.rada.data.repository.PlaylistRepository
import com.rada.data.repository.StationRepository
-import com.rada.data.wavlake.WavlakeApi
import com.rada.player.PlaybackManager
import dagger.Module
import dagger.Provides
@@ -62,8 +61,4 @@ object AppModule {
@ApplicationContext context: Context,
libraryRepository: LibraryRepository,
): PlaybackManager = PlaybackManager(context, libraryRepository)
-
- @Provides
- @Singleton
- fun provideWavlakeApi(): WavlakeApi = WavlakeApi()
}
diff --git a/app/src/main/java/com/rada/di/PaymentsModule.kt b/app/src/main/java/com/rada/di/PaymentsModule.kt
deleted file mode 100644
index 06b3c03..0000000
--- a/app/src/main/java/com/rada/di/PaymentsModule.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.rada.di
-
-import com.rada.payments.NwcProvider
-import com.rada.payments.PaymentService
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import javax.inject.Singleton
-
-@Module
-@InstallIn(SingletonComponent::class)
-object PaymentsModule {
-
- /**
- * Provider priority list. NWC first (user-connected wallet); LNURL and
- * Cashu providers land in later phases and just append here — no other
- * call site changes.
- */
- @Provides
- @Singleton
- fun providePaymentService(nwc: NwcProvider): PaymentService =
- PaymentService(providers = { listOf(nwc) })
-}
diff --git a/app/src/main/java/com/rada/domain/model/WavlakeModels.kt b/app/src/main/java/com/rada/domain/model/WavlakeModels.kt
deleted file mode 100644
index 21273bf..0000000
--- a/app/src/main/java/com/rada/domain/model/WavlakeModels.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.rada.domain.model
-
-/**
- * Normalized Wavlake catalog models. Field names follow the official
- * Wavlake Developer API (wavlake/developer-docs, swagger.yaml, MIT):
- * search/rankings/tracks/track/artist/album responses. No invented fields.
- */
-
-const val SOURCE_WAVLAKE = "wavlake"
-const val SOURCE_LOCAL = "local"
-const val SOURCE_OTHER = "other"
-
-data class WavlakeArtist(
- val id: String,
- val name: String,
- val genre: String? = null,
- val genres: List = emptyList(),
- val imageUrl: String? = null,
- val bio: String? = null,
- val source: String = SOURCE_WAVLAKE,
- val sourceUrl: String? = null,
- /** Lightning Address ONLY (e.g. artist@example.com). Never LNURL/lud06/lud16/npub here. */
- val lightningAddress: String? = null,
- /** Nostr pubkey hex, when the API exposes it (used for the NIP-57 zap flow). */
- val npubHex: String? = null,
-) {
- /** Derived ONLY from [lightningAddress]. See [com.rada.payments.hasValidLightningAddress]. */
- val canZap: Boolean
- get() = com.rada.payments.hasValidLightningAddress(lightningAddress)
-}
-
-data class WavlakeTrack(
- val id: String,
- val title: String,
- val artistId: String,
- val artistName: String,
- val genre: String? = null,
- val genres: List = emptyList(),
- val artworkUrl: String? = null,
- val durationMs: Long? = null,
- val audioUrl: String? = null,
- val source: String = SOURCE_WAVLAKE,
- val sourceUrl: String? = null,
- /** Nostr pubkey hex of the artist, when exposed (zap flow). */
- val artistNpubHex: String? = null,
- /** Album GUID (official track-zap `i`-tags + parent content id). */
- val albumId: String? = null,
-) {
- /** Officially payable: the track-zap endpoint only needs track + album ids. */
- val isZapPayable: Boolean
- get() = id.isNotBlank() && !albumId.isNullOrBlank() && !audioUrl.isNullOrBlank()
-}
diff --git a/app/src/main/java/com/rada/nostr/Bech32.kt b/app/src/main/java/com/rada/nostr/Bech32.kt
deleted file mode 100644
index 470bafc..0000000
--- a/app/src/main/java/com/rada/nostr/Bech32.kt
+++ /dev/null
@@ -1,156 +0,0 @@
-package com.rada.nostr
-
-/**
- * BIP-173 bech32 + NIP-19 TLV entities (npub, nsec, nprofile, nevent, naddr).
- * Pure Kotlin, no external deps.
- */
-object Bech32 {
- private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
- private val GENERATOR = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
-
- private fun polymod(values: IntArray): Int {
- var chk = 1
- for (v in values) {
- val b = chk ushr 25
- chk = ((chk and 0x1ffffff) shl 5) xor v
- for (i in 0..4) if (((b ushr i) and 1) != 0) chk = chk xor GENERATOR[i]
- }
- return chk
- }
-
- private fun hrpExpand(hrp: String): IntArray {
- val ret = IntArray(hrp.length * 2 + 1)
- for (i in hrp.indices) ret[i] = hrp[i].code ushr 5
- // ret[hrp.length] stays 0 (separator)
- for (i in hrp.indices) ret[hrp.length + 1 + i] = hrp[i].code and 31
- return ret
- }
-
- private fun verifyChecksum(hrp: String, data: IntArray): Boolean =
- polymod(hrpExpand(hrp) + data) == 1
-
- private fun createChecksum(hrp: String, data: IntArray): IntArray {
- val values = hrpExpand(hrp) + data + IntArray(6)
- val mod = polymod(values) xor 1
- return IntArray(6) { (mod ushr (5 * (5 - it))) and 31 }
- }
-
- fun encode(hrp: String, data5bit: IntArray): String {
- val combined = data5bit + createChecksum(hrp, data5bit)
- return hrp + "1" + combined.joinToString("") { CHARSET[it].toString() }
- }
-
- /** Returns (hrp, data5bit) or null on invalid input. */
- fun decode(bech: String): Pair? {
- val lower = bech.lowercase()
- val pos = lower.lastIndexOf('1')
- if (pos < 1 || pos + 7 > lower.length) return null
- val hrp = lower.substring(0, pos)
- val data = IntArray(lower.length - pos - 1)
- for (i in data.indices) {
- val idx = CHARSET.indexOf(lower[pos + 1 + i])
- if (idx == -1) return null
- data[i] = idx
- }
- if (!verifyChecksum(hrp, data)) return null
- return hrp to data.dropLast(6).toIntArray()
- }
-
- fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): IntArray {
- var acc = 0
- var bits = 0
- val out = ArrayList()
- val maxv = (1 shl toBits) - 1
- for (b in data) {
- val value = b.toInt() and 0xff
- acc = (acc shl fromBits) or value
- bits += fromBits
- while (bits >= toBits) {
- bits -= toBits
- out.add((acc ushr bits) and maxv)
- }
- }
- if (pad && bits > 0) out.add((acc shl (toBits - bits)) and maxv)
- return out.toIntArray()
- }
-
- fun convertBitsBack(data5: IntArray, toBits: Int = 8): ByteArray {
- var acc = 0
- var bits = 0
- val out = ArrayList()
- val maxv = (1 shl toBits) - 1
- for (value in data5) {
- acc = (acc shl 5) or value
- bits += 5
- if (bits >= toBits) {
- bits -= toBits
- out.add(((acc ushr bits) and maxv).toByte())
- }
- }
- return out.toByteArray()
- }
-}
-
-/** NIP-19 entities. */
-object Nip19 {
- fun npub(pubkeyHex: String): String =
- Bech32.encode("npub", Bech32.convertBits(hexToBytes(pubkeyHex), 8, 5, true))
-
- fun nsec(privkeyHex: String): String =
- Bech32.encode("nsec", Bech32.convertBits(hexToBytes(privkeyHex), 8, 5, true))
-
- /** nprofile = TLV {0: pubkey, 1: relay hints} */
- fun nprofile(pubkeyHex: String, relays: List): String {
- val tlv = ArrayList()
- fun tag(type: Int, payload: ByteArray) {
- tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList())
- }
- tag(0, hexToBytes(pubkeyHex))
- relays.take(3).forEach { tag(1, it.toByteArray()) }
- return Bech32.encode("nprofile",
- Bech32.convertBits(tlv.toByteArray(), 8, 5, true))
- }
-
- /** nevent = TLV {1: event_id, 3: relay, [5 kind]} */
- fun nevent(eventIdHex: String, relays: List = emptyList(), kind: Int? = null): String {
- val tlv = ArrayList()
- fun tag(type: Int, payload: ByteArray) {
- tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList())
- }
- tag(1, hexToBytes(eventIdHex))
- relays.take(3).forEach { tag(3, it.toByteArray()) }
- kind?.let { tag(5, byteArrayOf((it shr 8).toByte(), (it and 0xff).toByte())) }
- return Bech32.encode("nevent", Bech32.convertBits(tlv.toByteArray(), 8, 5, true))
- }
-
- /** naddr = TLV {0: identifier(d), 1: relays, 2: pubkey, [3: kind]} — for 3044x music. */
- fun naddr(dTag: String, pubkeyHex: String, kind: Int, relays: List = emptyList()): String {
- val tlv = ArrayList()
- fun tag(type: Int, payload: ByteArray) {
- tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList())
- }
- tag(0, dTag.toByteArray())
- relays.take(3).forEach { tag(3, it.toByteArray()) }
- tag(2, hexToBytes(pubkeyHex))
- tag(3, byteArrayOf((kind shr 8).toByte(), (kind and 0xff).toByte()))
- return Bech32.encode("naddr", Bech32.convertBits(tlv.toByteArray(), 8, 5, true))
- }
-
- fun decode(bech: String): Pair? {
- val (hrp, data5) = Bech32.decode(bech) ?: return null
- return hrp to Bech32.convertBitsBack(data5)
- }
-}
-
-fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
-
-fun hexToBytes(hex: String): ByteArray {
- val clean = hex.trim().removePrefix("0x")
- require(clean.length % 2 == 0 && clean.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
- "invalid hex"
- }
- return ByteArray(clean.length / 2) {
- ((Character.digit(clean[it * 2], 16) shl 4) + Character.digit(clean[it * 2 + 1], 16)).toByte()
- }
-}
-
diff --git a/app/src/main/java/com/rada/nostr/Nip04.kt b/app/src/main/java/com/rada/nostr/Nip04.kt
deleted file mode 100644
index 9770edd..0000000
--- a/app/src/main/java/com/rada/nostr/Nip04.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.rada.nostr
-
-import fr.acinq.secp256k1.Secp256k1
-import java.util.Base64 as JBase64
-import java.security.SecureRandom
-import javax.crypto.Cipher
-import javax.crypto.spec.IvParameterSpec
-import javax.crypto.spec.SecretKeySpec
-
-/**
- * NIP-04: ECDH shared secret + AES-256-CBC for encrypted DMs and the NWC
- * (NIP-47) JSON-RPC envelope. Uses the app-held key via [keys].
- */
-object Nip04 {
-
- /**
- * NIP-04 shared secret = raw x-coordinate of priv*otherPub, NOT hashed.
- * (ACINQ's ecdh() applies libsecp256k1's default sha256 hashfp, which is
- * *not* what Nostr wants — pubKeyTweakMul gives the serialized point.)
- */
- private fun ecdh(priv: ByteArray, pubkeyHex: String): ByteArray {
- val compressed = byteArrayOf(0x02) + hexToBytes(pubkeyHex)
- val point = Secp256k1.get().pubKeyTweakMul(compressed, priv)
- return point.copyOfRange(1, 33) // x-coordinate, parity-independent
- }
-
- fun encrypt(priv: ByteArray, recipientPubkeyHex: String, plaintext: String): String {
- val shared = ecdh(priv, recipientPubkeyHex)
- val iv = ByteArray(16).also { SecureRandom().nextBytes(it) }
- val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
- cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(shared, "AES"), IvParameterSpec(iv))
- val ct = cipher.doFinal(plaintext.toByteArray())
- return JBase64.getEncoder().encodeToString(ct) + "?iv=" +
- JBase64.getEncoder().encodeToString(iv)
- }
-
- fun decrypt(priv: ByteArray, senderPubkeyHex: String, payload: String): String? = runCatching {
- val (ctB64, ivB64) = payload.split("?iv=", limit = 2)
- val shared = ecdh(priv, senderPubkeyHex)
- val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
- cipher.init(
- Cipher.DECRYPT_MODE,
- SecretKeySpec(shared, "AES"),
- IvParameterSpec(JBase64.getDecoder().decode(ivB64)),
- )
- String(cipher.doFinal(JBase64.getDecoder().decode(ctB64)))
- }.getOrNull()
-}
diff --git a/app/src/main/java/com/rada/nostr/NostrEvent.kt b/app/src/main/java/com/rada/nostr/NostrEvent.kt
deleted file mode 100644
index 81ea317..0000000
--- a/app/src/main/java/com/rada/nostr/NostrEvent.kt
+++ /dev/null
@@ -1,143 +0,0 @@
-package com.rada.nostr
-
-import fr.acinq.secp256k1.Secp256k1
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.JsonArray
-import kotlinx.serialization.json.JsonElement
-import kotlinx.serialization.json.JsonNull
-import kotlinx.serialization.json.JsonPrimitive
-import kotlinx.serialization.json.buildJsonArray
-import kotlinx.serialization.json.buildJsonObject
-import kotlinx.serialization.json.jsonArray
-import kotlinx.serialization.json.jsonPrimitive
-import kotlinx.serialization.json.put
-import java.security.MessageDigest
-
-/**
- * NIP-01 event. Minimal, immutable, serialization exactly per spec so the
- * event id (SHA-256 of [serialized]) verifies on any relay/client.
- */
-data class NostrEvent(
- val id: String,
- val pubkey: String,
- val createdAt: Long,
- val kind: Int,
- val tags: List>,
- val content: String,
- val sig: String,
-) {
- /** Canonical NIP-01 serialization: [0,pubkey,created_at,kind,tags,content]. */
- fun serialized(): String {
- val obj = buildJsonArray {
- add(JsonPrimitive(0))
- add(JsonPrimitive(pubkey))
- add(JsonPrimitive(createdAt))
- add(JsonPrimitive(kind))
- add(buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } })
- add(JsonPrimitive(content))
- }
- return CANONICAL.encodeToString(JsonElement.serializer(), obj)
- }
-
- fun toJson(): String {
- val obj = buildJsonObject {
- put("id", id)
- put("pubkey", pubkey)
- put("created_at", createdAt)
- put("kind", kind)
- put("tags", buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } })
- put("content", content)
- put("sig", sig)
- }
- return CANONICAL.encodeToString(JsonElement.serializer(), obj)
- }
-
- fun tag(name: String): String? = tags.firstOrNull { it.firstOrNull() == name }?.getOrNull(1)
- fun tagValues(name: String): List = tags.filter { it.firstOrNull() == name }.mapNotNull { it.getOrNull(1) }
-
- /** Recompute id and verify schnorr sig. Never trust unverified relay data. */
- fun verify(): Boolean = runCatching {
- val expectedId = sha256Hex(serialized().toByteArray())
- if (expectedId != id) return false
- Secp256k1.get().verifySchnorr(
- hexToBytes(sig),
- hexToBytes(id),
- hexToBytes(pubkey),
- )
- }.getOrDefault(false)
-
- companion object {
- internal val CANONICAL = Json { prettyPrint = false; encodeDefaults = true }
-
- fun sha256Hex(bytes: ByteArray): String =
- MessageDigest.getInstance("SHA-256").digest(bytes).toHex()
-
- fun unsigned(
- pubkey: String,
- kind: Int,
- content: String,
- tags: List>,
- createdAt: Long = System.currentTimeMillis() / 1000,
- ): UnsignedEvent = UnsignedEvent(pubkey, createdAt, kind, tags, content)
-
- fun fromJson(raw: String): NostrEvent? = runCatching {
- val el = CANONICAL.parseToJsonElement(raw)
- val o = el as? kotlinx.serialization.json.JsonObject ?: return null
- NostrEvent(
- id = o["id"]?.jsonPrimitive?.content ?: return null,
- pubkey = o["pubkey"]?.jsonPrimitive?.content ?: return null,
- createdAt = o["created_at"]?.jsonPrimitive?.content?.toLongOrNull() ?: return null,
- kind = o["kind"]?.jsonPrimitive?.content?.toIntOrNull() ?: return null,
- tags = (o["tags"] as? JsonArray)?.map { a ->
- a.jsonArray.map { it.jsonPrimitive.content }
- } ?: emptyList(),
- content = (o["content"] as? JsonPrimitive)?.content ?: "",
- sig = o["sig"]?.jsonPrimitive?.content ?: return null,
- )
- }.getOrNull()
- }
-}
-
-data class UnsignedEvent(
- val pubkey: String,
- val createdAt: Long,
- val kind: Int,
- val tags: List>,
- val content: String,
-) {
- fun serialized(): String {
- val obj = buildJsonArray {
- add(JsonPrimitive(0))
- add(JsonPrimitive(pubkey))
- add(JsonPrimitive(createdAt))
- add(JsonPrimitive(kind))
- add(buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } })
- add(JsonPrimitive(content))
- }
- return NostrEvent.CANONICAL.encodeToString(JsonElement.serializer(), obj)
- }
-
- /** Sign with the app-held key (signer callback keeps key ownership in NostrKeys). */
- fun sign(signer: (ByteArray) -> String?): NostrEvent? {
- val digest = MessageDigest.getInstance("SHA-256").digest(serialized().toByteArray())
- val sig = signer(digest) ?: return null
- val id = digest.toHex()
- return NostrEvent(id, pubkey, createdAt, kind, tags, content, sig)
- }
-}
-
-/** Music kinds from the Wavlake music NIPs (parameterized replaceable, NIP-33). */
-object MusicKinds {
- const val TRACK = 30440
- const val ALBUM = 30441
- const val ARTIST = 30442
- const val PLAYLIST = 30443
-
- const val METADATA = 0
- const val TEXT_NOTE = 1
- const val FOLLOW_LIST = 3
- const val DELETION = 5
- const val REACTION = 7
- const val ZAP_REQUEST = 9734
- const val ZAP_RECEIPT = 9735
-}
diff --git a/app/src/main/java/com/rada/nostr/NostrKeys.kt b/app/src/main/java/com/rada/nostr/NostrKeys.kt
deleted file mode 100644
index 35409f1..0000000
--- a/app/src/main/java/com/rada/nostr/NostrKeys.kt
+++ /dev/null
@@ -1,223 +0,0 @@
-package com.rada.nostr
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import android.security.keystore.KeyGenParameterSpec
-import android.security.keystore.KeyProperties
-import android.util.Base64
-import dagger.hilt.android.qualifiers.ApplicationContext
-import fr.acinq.secp256k1.Secp256k1
-import java.security.KeyStore
-import javax.crypto.Cipher
-import javax.crypto.KeyGenerator
-import javax.crypto.SecretKey
-import javax.crypto.spec.GCMParameterSpec
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import org.json.JSONObject
-
-/** Well-known Amber package, used only for store fallback text. NIP-55 login itself is signer-agnostic. */
-const val AMBER_PACKAGE = "com.greenart7c3.nostrsigner"
-
-/**
- * Optional Nostr identity for the device (NIP-01 keys).
- *
- * Security posture:
- * - Private key is encrypted with an AndroidKeyStore AES-GCM key that is
- * non-exportable; only the ciphertext blob ever leaves the Keystore.
- * - The plaintext key exists in memory only while signing an event.
- * - Nostr is entirely optional: without an identity every other app
- * feature works unchanged, and nothing here ever blocks playback.
- */
-@Singleton
-class NostrKeys @Inject constructor(
- @ApplicationContext private val context: Context,
-) {
- private val alias = "satsamp_nostr_key"
- private val prefs = context.getSharedPreferences("nostr_identity", Context.MODE_PRIVATE)
-
- private val _pubkey = MutableStateFlow(null)
- /** Hex x-only pubkey, or null when the user hasn't created/imported an identity. */
- val pubkey: StateFlow = _pubkey.asStateFlow()
-
- /** True when the user has a Keystore identity (encrypted private key) or a manually imported pubkey. */
- val hasIdentity: Boolean get() = prefs.contains("ct") || prefs.contains("imported_pubkey")
-
- /** True when the user has an identity of any kind (local or imported pubkey). */
- val hasPubkey: Boolean get() = _pubkey.value != null
-
- /** True only when the private key lives on this device (Keystore) and local
- * signing works. An imported or external-signer (Amber) pubkey alone does
- * NOT allow local signing — callers must fall back (e.g. ephemeral keys). */
- val canSignLocally: Boolean get() = prefs.contains("ct")
-
- init {
- if (hasIdentity) {
- // If a Keystore identity exists, derive pubkey from it.
- if (prefs.contains("ct")) {
- runCatching {
- val priv = decryptSecret()
- if (priv != null) _pubkey.value = xOnlyPubkey(priv)
- }
- }
- // If a manually imported pubkey exists, use it.
- val imported = prefs.getString("imported_pubkey", null)
- if (imported != null && _pubkey.value == null) {
- _pubkey.value = imported
- }
- }
- }
-
- /** Package name of the external signer (NIP-55), if the user logged in that way. */
- val signerPackage: String?
- get() = prefs.getString("signer_package", null)
-
- /** True when any NIP-55 external signer (Amber, etc.) is installed. Requires the nostrsigner entry. */
- fun isExternalSignerInstalled(): Boolean = runCatching {
- val probe = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:"))
- if (context.packageManager.queryIntentActivities(probe, 0).isNotEmpty()) return true
- // Fallback: direct Amber package check (needs the queries entry).
- context.packageManager.getPackageInfo(AMBER_PACKAGE, 0)
- true
- }.getOrDefault(false)
-
- /** Build the NIP-55 `get_public_key` intent. Launch via rememberLauncherForActivityResult. */
- fun buildGetPubkeyIntent(): Intent =
- Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")).apply {
- putExtra("type", "get_public_key")
- putExtra(
- "permissions",
- """[{"type":"sign_event"},{"type":"nip44_encrypt"},{"type":"nip44_decrypt"}]""",
- )
- }
-
- /** Import a raw 64-char hex pubkey (NIP-55 results are hex, not npub). */
- fun importHexPubkey(hex: String): Boolean {
- val clean = hex.trim().lowercase()
- if (!clean.matches(Regex("[0-9a-f]{64}"))) return false
- _pubkey.value = clean
- prefs.edit().putString("imported_pubkey", clean).apply()
- return true
- }
-
- /** Persist the signer package from a `get_public_key` result for future sign_event calls. */
- fun saveSignerPackage(pkg: String) {
- prefs.edit().putString("signer_package", pkg).apply()
- }
-
- /** Create a fresh identity. Returns the npub. Refuses if one exists (no silent overwrite). */
- fun createIdentity(): String {
- check(!hasIdentity) { "identity already exists" }
- val rnd = java.security.SecureRandom()
- var key = ByteArray(32)
- do { rnd.nextBytes(key) } while (!Secp256k1.get().secKeyVerify(key))
- val pub = xOnlyPubkey(key)
- encryptAndStore(key)
- _pubkey.value = pub
- return Nip19.npub(pub)
- }
-
- /** Import an nsec (paste flow). Stored encrypted; the plaintext is wiped after use. */
- fun importNsec(nsec: String): Boolean {
- val (hrp, bytes) = Nip19.decode(nsec.trim()) ?: return false
- if (hrp != "nsec" || bytes.size != 32) return false
- if (!Secp256k1.get().secKeyVerify(bytes)) return false
- encryptAndStore(bytes)
- return true
- }
-
- /** Import just a pubkey (manual paste fallback).
- * Allows relay connectivity for profile lookups without a Keystore identity. */
- fun importPubkey(npub: String): Boolean {
- val hex = runCatching { Nip19.decode(npub.trim()) }.getOrNull()?.let { (hrp, bytes) ->
- if (hrp == "npub" && bytes.size == 32) bytes.joinToString("") { "%02x".format(it) } else null
- } ?: return false
- _pubkey.value = hex
- prefs.edit().putString("imported_pubkey", hex).apply()
- return true
- }
-
- fun npub(): String? = _pubkey.value?.let { Nip19.npub(it) }
-
- /**
- * Sign `digest` (32-byte SHA-256 of serialized event) with the stored key.
- * Returns 64-byte BIP-340 schnorr sig hex, or null when no identity.
- */
- fun signDigest(digest: ByteArray): String? {
- val priv = decryptSecret() ?: return null
- try {
- return Secp256k1.get().signSchnorr(digest, priv, null).toHex()
- } finally {
- priv.fill(0)
- }
- }
-
- /** NIP-47/NIP-44 style ECDH needs the full priv transiently too. */
- fun withPrivateKey(block: (ByteArray) -> T): T? {
- val priv = decryptSecret() ?: return null
- try { return block(priv) } finally { priv.fill(0) }
- }
-
- /** Erase the identity (ciphertext + keystore key). */
- fun clearIdentity() {
- prefs.edit().clear().apply()
- runCatching {
- KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
- .deleteEntry(alias)
- }
- _pubkey.value = null
- }
-
- // ── Keystore plumbing ────────────────────────────────────────────────
-
- private fun keystoreKey(): SecretKey {
- val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
- (ks.getKey(alias, null) as? SecretKey)?.let { return it }
- val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
- gen.init(
- KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
- .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
- .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
- .setKeySize(256)
- .build(),
- )
- return gen.generateKey()
- }
-
- private fun encryptAndStore(priv: ByteArray) {
- val cipher = Cipher.getInstance("AES/GCM/NoPadding")
- cipher.init(Cipher.ENCRYPT_MODE, keystoreKey())
- val ct = cipher.doFinal(priv)
- prefs.edit()
- .putString("ct", Base64.encodeToString(ct, Base64.NO_WRAP))
- .putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
- .apply()
- priv.fill(0)
- }
-
- private fun decryptSecret(): ByteArray? {
- val ct = prefs.getString("ct", null) ?: return null
- val iv = prefs.getString("iv", null) ?: return null
- return runCatching {
- val cipher = Cipher.getInstance("AES/GCM/NoPadding")
- cipher.init(
- Cipher.DECRYPT_MODE,
- keystoreKey(),
- GCMParameterSpec(128, Base64.decode(iv, Base64.NO_WRAP)),
- )
- cipher.doFinal(Base64.decode(ct, Base64.NO_WRAP))
- }.getOrNull()
- }
-
- companion object {
- /** compressed pubkey (33B) minus the 02/03 prefix = x-only (32B), NIP-01 style. */
- fun xOnlyPubkey(priv: ByteArray): String {
- val compressed = Secp256k1.get().pubkeyCreate(priv)
- return compressed.copyOfRange(1, 33).toHex()
- }
- }
-}
diff --git a/app/src/main/java/com/rada/nostr/NostrService.kt b/app/src/main/java/com/rada/nostr/NostrService.kt
deleted file mode 100644
index fcc6a68..0000000
--- a/app/src/main/java/com/rada/nostr/NostrService.kt
+++ /dev/null
@@ -1,233 +0,0 @@
-package com.rada.nostr
-
-import android.content.Context
-import dagger.hilt.android.qualifiers.ApplicationContext
-import java.util.concurrent.TimeUnit
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.launch
-import okhttp3.OkHttpClient
-import org.json.JSONArray
-import org.json.JSONObject
-
-data class RelayInfo(val url: String, val connected: Boolean)
-
-/**
- * The single Nostr entry point (publish/subscribe/query/profile/music).
- * UI must never talk to RelayConnection directly.
- *
- * Failure posture: relays are best-effort. If every relay is down, flows
- * simply never emit; nothing else in the app notices or degrades.
- */
-@Singleton
-class NostrService @Inject constructor(
- @ApplicationContext context: Context,
- private val keys: NostrKeys,
-) {
- private val prefs = context.getSharedPreferences("nostr_settings", Context.MODE_PRIVATE)
-
- private val client = OkHttpClient.Builder()
- .pingInterval(30, TimeUnit.SECONDS)
- .connectTimeout(10, TimeUnit.SECONDS)
- .readTimeout(0, TimeUnit.MILLISECONDS) // websockets: no read timeout
- .build()
-
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
-
- private val defaultRelays = listOf(
- "wss://relay.wavlake.com",
- "wss://relay.damus.io",
- "wss://relay.primal.net",
- "wss://nos.lol",
- )
-
- private val relayUrls: List
- get() {
- val saved = prefs.getString("relays", null)
- ?.split("\n")?.filter { it.isNotBlank() }
- val base = if (saved.isNullOrEmpty()) defaultRelays else saved
- // Wavlake's own relay always rides along (first): artist kind-0s
- // live there even when users customize the rest. Deduped.
- return (listOf("wss://relay.wavlake.com") + base).distinct()
- }
-
- private val relays = java.util.concurrent.ConcurrentHashMap()
-
- private val _relayStates = MutableStateFlow>(emptyList())
- val relayStates: StateFlow> = _relayStates.asStateFlow()
-
- /** Merged verified events from all relays (deduped by id downstream). */
- private val merged = kotlinx.coroutines.flow.MutableSharedFlow(
- extraBufferCapacity = 512,
- )
-
- @Volatile private var started = false
-
- /** Call once when the user enables Nostr; safe to call repeatedly. */
- fun ensureConnected() {
- if (started) return
- synchronized(this) {
- if (started) return
- started = true
- relayUrls.forEach { url ->
- val conn = RelayConnection(url, client)
- relays[url] = conn
- scope.launch {
- conn.state.collect { st ->
- _relayStates.value = relays.map { (u, c) ->
- RelayInfo(u, st is RelayConnection.State.Connected)
- }
- }
- }
- scope.launch { conn.events.collect { merged.emit(it) } }
- conn.connect()
- }
- _relayStates.value = relays.map { RelayInfo(it.key, false) }
- }
- }
-
- val events get() = merged as kotlinx.coroutines.flow.SharedFlow
-
- val enabled: Boolean
- get() = prefs.getBoolean("enabled", false) && keys.hasIdentity
-
- fun setEnabled(on: Boolean) {
- prefs.edit().putBoolean("enabled", on).apply()
- if (on) ensureConnected()
- else {
- relays.values.forEach { it.close() }
- started = false
- }
- }
-
- fun relaysConfig(): List = relayUrls
- fun setRelays(list: List) {
- prefs.edit().putString("relays", list.filter { it.isNotBlank() }.joinToString("\n")).apply()
- relays.values.forEach { it.close() }
- relays.clear()
- started = false
- if (enabled) ensureConnected()
- }
-
- // ── publish ───────────────────────────────────────────────────────────
-
- /** Publish a pre-signed event (zap requests use ephemeral keys). */
- fun publishRaw(event: NostrEvent) {
- ensureConnected()
- relays.values.forEach { it.publish(event) }
- }
-
- /** Await the lnurl server's zap receipt (kind 9735) referencing [requestId]. */
- suspend fun waitForZapReceipt(requestId: String, targetPubkey: String, timeoutMs: Long) {
- ensureConnected()
- runCatching {
- kotlinx.coroutines.withTimeout(timeoutMs) {
- events.first {
- it.kind == MusicKinds.ZAP_RECEIPT &&
- it.tagValues("e").contains(requestId) &&
- it.pubkey == targetPubkey
- }
- }
- }
- }
-
- fun publish(kind: Int, content: String, tags: List>): NostrEvent? {
- val ev = NostrEvent.unsigned(
- pubkey = keys.pubkey.value ?: return null,
- kind = kind, content = content, tags = tags,
- ).sign { digest -> keys.signDigest(digest) } ?: return null
- relays.values.forEach { it.publish(ev) }
- return ev
- }
-
- /** Kind 0 metadata (NIP-01 profile). */
- fun publishProfile(name: String, about: String, pictureUrl: String): NostrEvent? {
- val content = JSONObject().apply {
- put("name", name)
- put("about", about)
- if (pictureUrl.isNotBlank()) put("picture", pictureUrl)
- }.toString()
- return publish(MusicKinds.METADATA, content, emptyList())
- }
-
- /** Kind 7 reaction ("+") to an event, optional lightning zap request. */
- fun publishReaction(target: NostrEvent, content: String = "+"): NostrEvent? =
- publish(
- MusicKinds.REACTION, content,
- listOf(
- listOf("e", target.id),
- listOf("p", target.pubkey),
- ),
- )
-
- // ── music metadata (Wavlake music NIPs 30440-30443) ──────────────────
-
- /** Publish a track as kind 30440 with a NOM-flavored JSON payload. */
- fun publishTrack(
- dTag: String,
- title: String,
- creator: String,
- enclosureUrl: String,
- mimeType: String = "audio/mpeg",
- durationSec: Long = 0,
- ): NostrEvent? {
- val content = JSONObject().apply {
- put("title", title)
- put("guid", dTag)
- put("creator", creator)
- put("type", mimeType)
- put("duration", durationSec)
- put("published_at", (System.currentTimeMillis() / 1000).toString())
- put("enclosure", enclosureUrl)
- put("version", "0.1")
- }.toString()
- return publish(MusicKinds.TRACK, content, listOf(listOf("d", dTag)))
- }
-
- // ── query (one-shot-ish) ──────────────────────────────────────────────
-
- /** Subscribe with [filter]; collected events (verified) flow into [onEvent]. */
- fun query(filter: JSONObject, onEvent: (NostrEvent) -> Unit): String {
- ensureConnected()
- val ids = relays.values.map { it.subscribe(filter) }
- scope.launch {
- val seen = HashSet()
- events.collect { ev ->
- if (seen.add(ev.id)) onEvent(ev)
- }
- }
- return ids.firstOrNull() ?: ""
- }
-
- /** Fetch a profile (kind 0) for [pubkeyHex]; emits once if any relay has it. */
- fun getProfile(pubkeyHex: String, timeoutMs: Long = 6000): kotlinx.coroutines.flow.Flow {
- val out = kotlinx.coroutines.flow.MutableStateFlow(null)
- ensureConnected()
- val filter = JSONObject().apply {
- put("kinds", JSONArray().put(MusicKinds.METADATA))
- put("authors", JSONArray().put(pubkeyHex))
- put("limit", 1)
- }
- // The merged flow only carries what subscriptions request — without
- // this REQ the lookup below could only catch someone else's traffic.
- val subs = relays.values.map { conn -> conn to conn.subscribe(filter) }
- val job = scope.launch {
- events.first { it.kind == MusicKinds.METADATA && it.pubkey == pubkeyHex }
- .let { out.value = it }
- }
- scope.launch {
- kotlinx.coroutines.delay(timeoutMs)
- job.cancel()
- subs.forEach { (conn, id) -> runCatching { conn.unsubscribe(id) } }
- }
- return out
- }
-}
diff --git a/app/src/main/java/com/rada/nostr/RelayConnection.kt b/app/src/main/java/com/rada/nostr/RelayConnection.kt
deleted file mode 100644
index 532bbd1..0000000
--- a/app/src/main/java/com/rada/nostr/RelayConnection.kt
+++ /dev/null
@@ -1,148 +0,0 @@
-package com.rada.nostr
-
-import android.util.Log
-import java.util.concurrent.ConcurrentHashMap
-import java.util.concurrent.atomic.AtomicLong
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.channels.BufferOverflow
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import okhttp3.OkHttpClient
-import okhttp3.Request
-import okhttp3.Response
-import okhttp3.WebSocket
-import okhttp3.WebSocketListener
-import org.json.JSONArray
-import org.json.JSONObject
-
-/**
- * Minimal NIP-01 relay client over OkHttp WebSocket — no new dependencies.
- * Design guarantees for the app:
- * - Every method is safe to call with relays down: failures surface as
- * closed flows / nulls, never exceptions into the UI, never blocking
- * playback.
- * - Events arriving from relays are [NostrEvent.verify]-checked before
- * they are emitted; untrusted relay data can't inject fakes.
- */
-class RelayConnection(
- val url: String,
- private val client: OkHttpClient,
-) {
- sealed interface State {
- data object Connecting : State
- data object Connected : State
- data class Disconnected(val reason: String?) : State
- }
-
- private val _state = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
- val state: SharedFlow = _state.asSharedFlow()
-
- private val _events = MutableSharedFlow(
- replay = 0, extraBufferCapacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST,
- )
- /** Verified events matching any subscription on this relay. */
- val events: SharedFlow = _events.asSharedFlow()
-
- /** reqId -> original filter JSON, so we can CLOSE. */
- private val subs = ConcurrentHashMap()
- private val reqCounter = AtomicLong(0)
- @Volatile private var socket: WebSocket? = null
- private val pendingOut = ArrayDeque()
-
- val isConnected: Boolean get() = socket != null
-
- fun connect() {
- if (socket != null) return
- _state.tryEmit(State.Connecting)
- runCatching {
- socket = client.newWebSocket(
- Request.Builder().url(url).build(),
- object : WebSocketListener() {
- override fun onOpen(webSocket: WebSocket, response: Response) {
- Log.d(TAG, "connected: $url")
- _state.tryEmit(State.Connected)
- // Re-send subscriptions and queued events after (re)connect.
- subs.values.forEach { w -> runCatching { webSocket.send(w.toString()) } }
- while (pendingOut.isNotEmpty()) {
- webSocket.send(pendingOut.removeFirst())
- }
- }
-
- override fun onMessage(webSocket: WebSocket, text: String) = handleFrame(text)
-
- override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
- Log.w(TAG, "connection failed: $url: ${t.message}")
- socket = null
- _state.tryEmit(State.Disconnected(t.message))
- }
-
- override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
- Log.d(TAG, "closed: $url ($code)")
- socket = null
- _state.tryEmit(State.Disconnected(reason))
- }
- },
- )
- }.onFailure {
- _state.tryEmit(State.Disconnected(it.message))
- }
- }
-
- private fun handleFrame(text: String) {
- runCatching {
- val arr = JSONArray(text)
- when (arr.optString(0)) {
- "EVENT" -> {
- val ev = NostrEvent.fromJson(arr.optString(2))
- // Trust boundary: id + schnorr signature must verify.
- if (ev != null && ev.verify()) _events.tryEmit(ev)
- }
- // ["OK", evt_id, ok|fail, msg] and ["EOSE", sub_id] are handled
- // by callers that need them via their own flow when extended;
- // for now presence is enough.
- }
- }
- }
-
- /** NIP-01 REQ. Returns subscription id; results arrive on [events]. */
- fun subscribe(filter: JSONObject): String {
- val id = "s${reqCounter.incrementAndGet()}"
- // NIP-01: ["REQ", sub_id, filter]
- subs[id] = JSONArray().put("REQ").put(id).put(filter)
- val ws = socket
- // send() on a non-open socket fails silently — the pending REQ is
- // re-sent from onOpen, so delivery is still guaranteed.
- val sent = if (ws != null) runCatching { ws.send(subs[id].toString()) }.getOrDefault(false) else false
- Log.d(TAG, "REQ $id -> $url (immediate=$sent)")
- return id
- }
-
- fun unsubscribe(id: String) {
- subs.remove(id)
- // NIP-01 CLOSE is an array, not an object.
- val close = JSONArray().put("CLOSE").put(id).toString()
- runCatching { socket?.send(close) }
- }
-
- fun publish(event: NostrEvent) {
- val msg = JSONArray().apply {
- put("EVENT")
- put(JSONObject(event.toJson()))
- }.toString()
- val ws = socket
- if (ws != null && ws.send(msg)) return
- // Queue for the next successful connect; bounded.
- if (pendingOut.size < 32) pendingOut.addLast(msg)
- }
-
- fun close() {
- subs.clear()
- runCatching { socket?.close(1000, null) }
- socket = null
- }
-
- companion object {
- private const val TAG = "Relay"
- }
-}
diff --git a/app/src/main/java/com/rada/payments/LightningAddress.kt b/app/src/main/java/com/rada/payments/LightningAddress.kt
deleted file mode 100644
index a1d50bd..0000000
--- a/app/src/main/java/com/rada/payments/LightningAddress.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.rada.payments
-
-/**
- * Strict Lightning Address validation. A zap affordance may ONLY be driven
- * by [hasValidLightningAddress]; never by Wavlake membership, Nostr keys,
- * LNURL, lud06/lud16, payment URLs, or any other Lightning metadata.
- */
-fun hasValidLightningAddress(lightningAddress: String?): Boolean {
- if (lightningAddress == null) return false
- val value = lightningAddress.trim()
- if (value.isEmpty()) return false
- val parts = value.split("@")
- if (parts.size != 2) return false
- val username = parts[0].trim()
- val domain = parts[1].trim()
- if (username.isEmpty() || domain.isEmpty()) return false
- if (domain.contains(" ")) return false
- if (!domain.contains(".")) return false
- return true
-}
-
-/** Normalized form of a valid address, or null. */
-fun normalizedLightningAddress(lightningAddress: String?): String? {
- if (!hasValidLightningAddress(lightningAddress)) return null
- val parts = lightningAddress!!.trim().split("@")
- return "${parts[0].trim()}@${parts[1].trim()}"
-}
diff --git a/app/src/main/java/com/rada/payments/NwcProvider.kt b/app/src/main/java/com/rada/payments/NwcProvider.kt
deleted file mode 100644
index 04b839d..0000000
--- a/app/src/main/java/com/rada/payments/NwcProvider.kt
+++ /dev/null
@@ -1,286 +0,0 @@
-package com.rada.payments
-
-import android.content.Context
-import android.security.keystore.KeyGenParameterSpec
-import android.security.keystore.KeyProperties
-import android.util.Base64
-import com.rada.nostr.Nip04
-import com.rada.nostr.NostrEvent
-import com.rada.nostr.NostrKeys
-import com.rada.nostr.NostrService
-import dagger.hilt.android.qualifiers.ApplicationContext
-import java.security.KeyStore
-import java.util.Base64 as JBase64
-import javax.crypto.Cipher
-import javax.crypto.KeyGenerator
-import javax.crypto.SecretKey
-import javax.crypto.spec.GCMParameterSpec
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.withContext
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withTimeoutOrNull
-import org.json.JSONObject
-
-/**
- * Nostr Wallet Connect (NIP-47) provider: the app talks to the user's
- * wallet *through their relay* using encrypted kind-13194 info events and
- * kind-13195/23194/23195 request/response DMs. The app never holds wallet
- * keys; it holds only the pairing connection string (encrypted at rest).
- *
- * Connection string format (from Alby Hub / coinos etc.):
- * nostr+walletconnect://?relay=&secret=
- */
-@Singleton
-class NwcProvider @Inject constructor(
- @ApplicationContext private val context: Context,
- private val nostr: NostrService,
- private val keys: NostrKeys,
-) : PaymentProvider {
-
- override val id = "nwc"
-
- private val prefs = context.getSharedPreferences("nwc", Context.MODE_PRIVATE)
-
- private var walletPubkey: String? = null
- private var relayUrl: String? = null
- private var secretHex: String? = null
-
- val connected: Boolean get() = parseStored() != null
-
- override val isConfigured: Boolean get() = connected
-
- data class Pairing(val walletPubkey: String, val relayUrl: String, val secretHex: String)
-
- /** Parse + validate a pairing string. Stores it; returns false if malformed. */
- fun connect(pairingUri: String): Boolean {
- val parsed = parse(pairingUri.trim()) ?: return false
- // store encrypted (it contains a secret key!)
- storeSecret(pairingUri.trim())
- walletPubkey = parsed.walletPubkey
- relayUrl = parsed.relayUrl
- secretHex = parsed.secretHex
- return true
- }
-
- fun disconnect() {
- prefs.edit().clear().apply()
- walletPubkey = null; relayUrl = null; secretHex = null
- }
-
- private fun parse(uri: String): Pairing? = runCatching {
- require(uri.startsWith("nostr+walletconnect://")) { "not an NWC uri" }
- val rest = uri.removePrefix("nostr+walletconnect://")
- val pubkey = rest.substringBefore("?")
- require(pubkey.length == 64 && pubkey.all { it in '0'..'9' || it in 'a'..'f' }) { "bad pubkey" }
- val qs = rest.substringAfter("?", "")
- val params = qs.split("&").mapNotNull {
- val k = it.substringBefore("="); val v = it.substringAfter("=", "")
- k to java.net.URLDecoder.decode(v, "UTF-8")
- }.toMap()
- val relay = params["relay"] ?: return null
- val secret = params["secret"] ?: return null
- require(secret.length == 64) { "bad secret" }
- Pairing(pubkey, relay, secret)
- }.getOrNull()
-
- private fun parseStored(): Pairing? {
- if (walletPubkey != null) return Pairing(walletPubkey!!, relayUrl!!, secretHex!!)
- val ct = prefs.getString("pairing_ct", null) ?: return null
- val iv = prefs.getString("pairing_iv", null) ?: return null
- val uri = decrypt(ct, iv) ?: return null
- val p = parse(uri) ?: return null
- walletPubkey = p.walletPubkey; relayUrl = p.relayUrl; secretHex = p.secretHex
- return p
- }
-
- // ── provider API ──────────────────────────────────────────────────────
-
- override suspend fun payLightningAddress(
- address: String,
- sats: Long,
- memo: String?,
- ): PaymentResult {
- // NIP-47 pay_invoice needs a bolt11; a lightning *address* first
- // resolves via LUD-06 to an invoice.
- val identifier = if (address.contains("@")) {
- resolveLightningAddress(address, sats, memo)
- ?: return PaymentResult.Failed("lnurl failed", "Couldn't reach that Lightning address.")
- } else address
- return nwcRequest("pay_invoice", JSONObject().apply {
- put("invoice", identifier)
- })
- }
-
- override suspend fun payInvoice(bolt11: String, sats: Long): PaymentResult =
- nwcRequest("pay_invoice", JSONObject().apply { put("invoice", bolt11) })
-
- override suspend fun createInvoice(sats: Long, memo: String?): PaymentResult =
- nwcRequest("make_invoice", JSONObject().apply {
- put("amount", sats * 1000) // msats
- memo?.let { put("description", it) }
- })
-
- /** NIP-06/LUD-16: lightning address -> {callback}?amount= -> bolt11 */
- private suspend fun resolveLightningAddress(
- address: String,
- sats: Long,
- memo: String?,
- ): String? = withContext(Dispatchers.IO) {
- runCatching {
- val (name, domain) = address.split("@", limit = 2)
- val https = "https://$domain/.well-known/lnurlp/$name"
- val client = okhttp3.OkHttpClient.Builder()
- .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
- .build()
- val lnl = client.newCall(okhttp3.Request.Builder().url(https).build())
- .execute().use { r ->
- if (!r.isSuccessful) return@runCatching null
- JSONObject(r.body?.string() ?: return@runCatching null)
- }
- if (lnl.optString("tag") != "payRequest") return@runCatching null
- val min = lnl.optLong("minSendable", 1000L) // msats
- val max = lnl.optLong("maxSendable", Long.MAX_VALUE)
- val msats = (sats * 1000).coerceIn(min, max)
- val cb = lnl.getString("callback")
- val url = "$cb${if (cb.contains("?")) "&" else "?"}amount=$msats" +
- (memo?.let { "&comment=${java.net.URLEncoder.encode(it, "UTF-8")}" } ?: "")
- val resp = client.newCall(okhttp3.Request.Builder().url(url).build())
- .execute().use { r -> JSONObject(r.body?.string() ?: return@runCatching null) }
- if (resp.has("error")) {
- return@runCatching null
- }
- resp.optString("pr").takeIf { it.isNotBlank() }
- }.getOrNull()
- }
-
- /**
- * Send a NIP-47 request event, await the wallet's encrypted response.
- * Timeout-safe: returns Failed, never hangs the caller.
- */
- private suspend fun nwcRequest(method: String, params: JSONObject): PaymentResult {
- val pairing = parseStored() ?: return PaymentResult.Failed(
- "not connected", "Connect your wallet in Settings → Nostr.",
- )
- val ourPub = keys.pubkey.value ?: return PaymentResult.Failed(
- "no identity", "Create a Nostr identity first (Settings → Nostr).",
- )
-
- val deferred = CompletableDeferred()
- // Subscribe to the wallet's response kind for our pubkey (NIP-47: 23195).
- val filter = JSONObject().apply {
- put("kinds", org.json.JSONArray().put(23195))
- put("authors", org.json.JSONArray().put(pairing.walletPubkey))
- put("#p", org.json.JSONArray().put(ourPub))
- put("limit", 5)
- }
- val requestId = java.util.UUID.randomUUID().toString()
- val payload = JSONObject().apply {
- put("method", method)
- put("params", params)
- }
-
- val enc = keys.withPrivateKey { priv ->
- Nip04.encrypt(priv, pairing.walletPubkey, payload.toString())
- } ?: return PaymentResult.Failed("crypto", "Couldn't encrypt the request.")
-
- // Publish request (kind 23194) to the NWC relay AND app relays.
- val ev = NostrEvent.unsigned(
- pubkey = ourPub, kind = 23194, content = enc,
- tags = listOf(listOf("p", pairing.walletPubkey)),
- ).sign { d -> keys.signDigest(d) }
- ?: return PaymentResult.Failed("signing", "Couldn't sign the request.")
-
- // A dedicated relay connection to the NWC relay from the pairing.
- val nwcRelay = com.rada.nostr.RelayConnection(
- pairing.relayUrl,
- okhttp3.OkHttpClient.Builder()
- .pingInterval(20, java.util.concurrent.TimeUnit.SECONDS).build(),
- )
- return try {
- nwcRelay.connect()
- // Listen for the response on the NWC relay only.
- val listen = CoroutineScope(Dispatchers.IO + SupervisorJob())
- val listenJob = listen.launch {
- nwcRelay.events.collect { e ->
- if (e.kind == 23195 && !deferred.isCompleted) {
- val plain = keys.withPrivateKey { priv ->
- Nip04.decrypt(priv, pairing.walletPubkey, e.content)
- } ?: return@collect
- runCatching { JSONObject(plain) }
- .onSuccess { deferred.complete(it) }
- }
- }
- }
- nwcRelay.publish(ev)
- val result = withTimeoutOrNull(15_000) { deferred.await() }
- listenJob.cancel()
- result?.let { json ->
- when {
- json.has("error") -> PaymentResult.Failed(
- json.optString("error"),
- "Wallet declined: ${json.optString("error_message", json.optString("error"))}",
- )
- json.has("result_type") -> {
- val res = json.optJSONObject("result")
- when (method) {
- "make_invoice" -> PaymentResult.InvoiceCreated(
- res?.optString("invoice") ?: "", pairing.walletPubkey,
- )
- "pay_invoice" -> PaymentResult.Paid
- "get_info" -> PaymentResult.Paid
- else -> PaymentResult.Paid
- }
- }
- else -> PaymentResult.Failed("bad response", "Unexpected wallet response.")
- }
- } ?: PaymentResult.Failed("timeout", "Wallet didn't respond in time.")
- } finally {
- nwcRelay.close()
- }
- }
-
- /** Wallet info (NIP-47 get_info) — names the connected wallet. */
- suspend fun getInfo(): String? {
- val r = nwcRequest("get_info", JSONObject())
- return if (r is PaymentResult.Paid) "connected" else null
- }
-
- // ── keystore at-rest protection for the pairing secret ───────────────
-
- private fun ksKey(): SecretKey {
- val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
- (ks.getKey("satsamp_nwc", null) as? SecretKey)?.let { return it }
- val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
- gen.init(
- KeyGenParameterSpec.Builder("satsamp_nwc",
- KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
- .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
- .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
- .build(),
- )
- return gen.generateKey()
- }
-
- private fun storeSecret(uri: String) {
- val cipher = Cipher.getInstance("AES/GCM/NoPadding")
- cipher.init(Cipher.ENCRYPT_MODE, ksKey())
- prefs.edit()
- .putString("pairing_ct", Base64.encodeToString(cipher.doFinal(uri.toByteArray()), Base64.NO_WRAP))
- .putString("pairing_iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
- .apply()
- }
-
- private fun decrypt(ctB64: String, ivB64: String): String? = runCatching {
- val cipher = Cipher.getInstance("AES/GCM/NoPadding")
- cipher.init(
- Cipher.DECRYPT_MODE, ksKey(),
- GCMParameterSpec(128, Base64.decode(ivB64, Base64.NO_WRAP)),
- )
- String(cipher.doFinal(Base64.decode(ctB64, Base64.NO_WRAP)))
- }.getOrNull()
-}
diff --git a/app/src/main/java/com/rada/payments/PaymentService.kt b/app/src/main/java/com/rada/payments/PaymentService.kt
deleted file mode 100644
index 87d4ccc..0000000
--- a/app/src/main/java/com/rada/payments/PaymentService.kt
+++ /dev/null
@@ -1,112 +0,0 @@
-package com.rada.payments
-
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-
-/**
- * Payment abstraction — the rest of the app only ever talks to
- * [PaymentService]; provider specifics (NWC, LNURL, Lightning Address,
- * Cashu later) stay behind [PaymentProvider].
- *
- * Guarantees the app relies on:
- * - No payment failure ever propagates into playback code.
- * - Nothing auto-spends: pay() is only invoked from explicit UI actions
- * or bounded value-for-value logic with user-set caps.
- */
-sealed interface PaymentResult {
- data class InvoiceCreated(val bolt11: String, val lightningAddress: String?) : PaymentResult
- data object Paid : PaymentResult
- data class Failed(val reason: String, val userFacing: String) : PaymentResult
-}
-
-interface PaymentProvider {
- val id: String
- val isConfigured: Boolean
-
- /** Pay [sats] to a lightning address / lnurl (LUD-06/LUD-07/LUD-16 flow). */
- suspend fun payLightningAddress(address: String, sats: Long, memo: String?): PaymentResult
-
- /** Create an invoice to ourselves (NWC make_invoice) — for receiving. */
- suspend fun createInvoice(sats: Long, memo: String?): PaymentResult
-
- /** Pay a bolt11 invoice directly (zap flow). */
- suspend fun payInvoice(bolt11: String, sats: Long): PaymentResult
-}
-
-/**
- * Central payment service. Providers are consulted in priority order;
- * when none is configured every method fails fast with a clear reason
- * and the UI renders the plain "⚡ Support" flow as connect-prompt.
- */
-class PaymentService(
- private val providers: () -> List,
-) {
- private val _history = MutableStateFlow>(emptyList())
- val history: StateFlow> = _history.asStateFlow()
-
- val hasAnyWallet: Boolean get() = providers().any { it.isConfigured }
-
- suspend fun tipArtist(
- lightningAddress: String?,
- sats: Long,
- artistName: String,
- trackTitle: String? = null,
- ): PaymentResult {
- if (sats <= 0) return PaymentResult.Failed("invalid amount", "Enter an amount first.")
- if (lightningAddress.isNullOrBlank()) {
- return PaymentResult.Failed("no address", "$artistName has no Lightning address set.")
- }
- val provider = providers().firstOrNull { it.isConfigured }
- ?: return PaymentResult.Failed(
- "no wallet",
- "Connect a wallet first (Settings → Nostr → Bitcoin).",
- )
- val memo = listOfNotNull(trackTitle?.takeIf { it.isNotBlank() }, "via SatsAmp")
- .joinToString(" · ")
- val result = runCatching { provider.payLightningAddress(lightningAddress, sats, memo) }
- .getOrElse {
- PaymentResult.Failed("provider error: ${it.message}", "Payment failed — try again.")
- }
- _history.value = listOf(
- PaymentRecord(
- timestampMs = System.currentTimeMillis(),
- artist = artistName,
- track = trackTitle,
- sats = sats,
- method = provider.id,
- success = result is PaymentResult.Paid || result is PaymentResult.InvoiceCreated,
- detail = when (result) {
- is PaymentResult.Failed -> result.reason
- is PaymentResult.InvoiceCreated -> result.bolt11.take(24)
- else -> null
- },
- ),
- ) + _history.value
- return result
- }
-
- suspend fun payInvoiceDirect(bolt11: String, sats: Long): PaymentResult {
- val provider = providers().firstOrNull { it.isConfigured }
- ?: return PaymentResult.Failed("no wallet", "No wallet connected.")
- return runCatching { provider.payInvoice(bolt11, sats) }
- .getOrElse { PaymentResult.Failed("provider error: ${it.message}", "Payment failed.") }
- }
-
- suspend fun createInvoice(sats: Long, memo: String?): PaymentResult {
- val provider = providers().firstOrNull { it.isConfigured }
- ?: return PaymentResult.Failed("no wallet", "No wallet connected.")
- return runCatching { provider.createInvoice(sats, memo) }
- .getOrElse { PaymentResult.Failed("provider error: ${it.message}", "Invoice failed.") }
- }
-}
-
-data class PaymentRecord(
- val timestampMs: Long,
- val artist: String,
- val track: String?,
- val sats: Long,
- val method: String,
- val success: Boolean,
- val detail: String?,
-)
diff --git a/app/src/main/java/com/rada/payments/ZapService.kt b/app/src/main/java/com/rada/payments/ZapService.kt
deleted file mode 100644
index c5f19cf..0000000
--- a/app/src/main/java/com/rada/payments/ZapService.kt
+++ /dev/null
@@ -1,285 +0,0 @@
-package com.rada.payments
-
-import android.util.Base64
-import android.util.Log
-import com.rada.data.wavlake.WavlakeZap
-import com.rada.nostr.MusicKinds
-import com.rada.nostr.NostrEvent
-import com.rada.nostr.NostrKeys
-import com.rada.nostr.NostrService
-import com.rada.nostr.hexToBytes
-import com.rada.nostr.toHex
-import fr.acinq.secp256k1.Secp256k1
-import java.security.SecureRandom
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import okhttp3.OkHttpClient
-import org.json.JSONArray
-import org.json.JSONObject
-
-/**
- * NIP-57 zaps, end to end:
- * target npub -> kind-0 profile -> lud16 -> LUD-06 lnurl ->
- * zap request (kind 9734, signed by an ephemeral key per NIP-57) ->
- * lnurl callback with `nostr=` -> bolt11 -> paid via [PaymentService]
- * (NWC today; any provider later).
- *
- * Every failure is reported as ZapOutcome.Failed with a human line; the
- * caller must treat zaps as strictly optional garnish on playback.
- */
-@Singleton
-private object StopCollect : Exception()
-
-class ZapService @Inject constructor(
- private val nostr: NostrService,
- private val keys: NostrKeys,
- private val paymentService: PaymentService,
-) {
- sealed interface ZapOutcome {
- data object Sent : ZapOutcome
- data class Failed(val reason: String) : ZapOutcome
- }
-
- /** Official Wavlake track-zap outcome (no artist address involved). */
- sealed interface WavlakeZapOutcome {
- data object Sent : WavlakeZapOutcome
- /** Invoice minted — payable in-app, or copied to any wallet. */
- data class Invoice(val bolt11: String, val sats: Long, val requestId: String) : WavlakeZapOutcome
- data class Failed(val reason: String) : WavlakeZapOutcome
- }
-
- private val http = OkHttpClient.Builder()
- .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
- .build()
-
- suspend fun zapRecipient(
- targetNpubHex: String,
- sats: Long,
- comment: String = "",
- eventId: String? = null,
- ): ZapOutcome {
- if (sats <= 0) return ZapOutcome.Failed("Enter an amount first.")
- val relays = nostr.relaysConfig()
-
- // 1. Target profile -> lud16 (lightning address).
- val lud16: String? = withContext(Dispatchers.IO) {
- var found: String? = null
- try {
- kotlinx.coroutines.withTimeout(6_000) {
- nostr.getProfile(targetNpubHex).collect { ev ->
- if (ev != null && found == null) {
- found = JSONObject(ev.content).optString("lud16")
- .takeIf { it.isNotBlank() }
- if (found != null) throw StopCollect
- }
- }
- }
- } catch (e: StopCollect) { /* found set */ }
- catch (e: kotlinx.coroutines.TimeoutCancellationException) { /* none */ }
- found
- } ?: return ZapOutcome.Failed("No Lightning address (lud16) on that profile.")
-
- val address: String = lud16 ?: return ZapOutcome.Failed("No Lightning address set.")
- return zapLightningAddress(address, sats, comment, targetNpubHex, eventId, relays)
- }
-
- suspend fun zapLightningAddress(
- lud16: String,
- sats: Long,
- comment: String,
- targetNpubHex: String,
- eventId: String?,
- relays: List,
- ): ZapOutcome = withContext(Dispatchers.IO) {
- // 2. LUD-06: https://domain/.well-known/lnurlp/name
- val lnl = runCatching {
- val (name, domain) = lud16.split("@", limit = 2)
- http.newCall(
- okhttp3.Request.Builder()
- .url("https://$domain/.well-known/lnurlp/$name").build(),
- ).execute().use { r ->
- if (!r.isSuccessful) return@runCatching null
- JSONObject(r.body?.string() ?: return@runCatching null)
- }
- }.getOrNull() ?: return@withContext ZapOutcome.Failed("Couldn't reach the Lightning endpoint.")
-
- if (lnl.optString("tag") != "payRequest")
- return@withContext ZapOutcome.Failed("Endpoint isn't a pay request.")
-
- val min = lnl.optLong("minSendable", 1000L) / 1000
- val max = lnl.optLong("maxSendable", Long.MAX_VALUE) / 1000
- if (sats < min || sats > max)
- return@withContext ZapOutcome.Failed("Amount must be between $min and $max sats.")
-
- // 3. Zap request event (NIP-57): signed by an EPHEMERAL key, not the
- // user's identity key; `relays` tag points at our relays so the
- // lnurl server can fetch it and verify.
- val ephemeral = ByteArray(32).also { SecureRandom().nextBytes(it) }
- .also { if (!Secp256k1.get().secKeyVerify(it)) return@withContext ZapOutcome.Failed("rng busy, retry") }
- val ephPub = Secp256k1.get().pubkeyCreate(ephemeral).copyOfRange(1, 33).toHex()
-
- val tags = mutableListOf(
- listOf("p", targetNpubHex),
- listOf("relays", *relays.take(3).toTypedArray()),
- listOf("amount", (sats * 1000).toString()),
- )
- if (eventId != null) tags.add(listOf("e", eventId))
-
- val zapReq = NostrEvent.unsigned(
- pubkey = ephPub, kind = MusicKinds.ZAP_REQUEST, content = comment, tags = tags,
- ).sign { digest -> Secp256k1.get().signSchnorr(digest, ephemeral, null).toHex() }
- ?: return@withContext ZapOutcome.Failed("Couldn't build the zap request.")
-
- // Publish the zap request so the lnurl server can fetch it.
- nostr.ensureConnected()
- // (NostrService.publish signs with the *identity* key — for zap
- // requests we must publish the pre-signed ephemeral event directly.)
- nostr.publishRaw(zapReq)
-
- // 4. Callback with nostr= param -> invoice.
- val allowsNostr = lnl.optBoolean("allowsNostr", false)
- if (!allowsNostr && comment.isNotEmpty()) {
- // still payable without comment? proceed without nostr tag
- }
- val cb = lnl.optString("callback")
- if (cb.isBlank()) return@withContext ZapOutcome.Failed("Endpoint missing callback.")
- val url = buildString {
- append(cb)
- append(if (cb.contains("?")) "&" else "?")
- append("amount=").append(sats * 1000)
- if (allowsNostr) {
- append("&nostr=").append(
- java.net.URLEncoder.encode(zapReq.toJson(), "UTF-8"),
- )
- }
- }
- val resp = runCatching {
- http.newCall(okhttp3.Request.Builder().url(url).build())
- .execute().use { r -> JSONObject(r.body?.string() ?: return@runCatching null) }
- }.getOrNull() ?: return@withContext ZapOutcome.Failed("Zap callback unreachable.")
-
- if (resp.has("error")) {
- return@withContext ZapOutcome.Failed(resp.optString("reason", "Zap rejected."))
- }
- val bolt11 = resp.optString("pr")
- if (bolt11.isBlank()) return@withContext ZapOutcome.Failed("No invoice returned.")
-
- // 5. Pay via the connected wallet.
- when (val pay = paymentService.payInvoiceDirect(bolt11, sats)) {
- is PaymentResult.Paid -> {
- nostr.waitForZapReceipt(zapReq.id, targetNpubHex, timeoutMs = 10_000)
- ZapOutcome.Sent
- }
- is PaymentResult.Failed -> ZapOutcome.Failed(pay.userFacing)
- else -> ZapOutcome.Failed("Unexpected wallet response.")
- }
- }
-
- /**
- * Official Wavlake track zap (wavlake/mobile protocol): mint an invoice
- * for a catalog track. Needs no artist address — the platform key is the
- * recipient and Wavlake splits downstream. Wallet-free: returns the
- * invoice for copy-out when no wallet is connected.
- */
- suspend fun wavlakeTrackInvoice(
- trackId: String,
- albumId: String,
- sats: Long,
- comment: String = "",
- ): WavlakeZapOutcome = withContext(Dispatchers.IO) {
- if (sats <= 0) return@withContext WavlakeZapOutcome.Failed("Enter an amount first.")
- if (trackId.isBlank() || albumId.isBlank()) {
- return@withContext WavlakeZapOutcome.Failed("This track can't be zapped yet.")
- }
- // Identity key preferred (official client behavior) when the private
- // key is on this device; ephemeral per NIP-57 otherwise (including
- // external-signer logins like Amber, which hold no local key).
- // Either way a real schnorr signature.
- val identityPub = keys.pubkey.value?.takeIf { it.isNotBlank() && keys.canSignLocally }
- val ephemeral = if (identityPub == null) {
- ByteArray(32).also { SecureRandom().nextBytes(it) }
- .also {
- if (!Secp256k1.get().secKeyVerify(it)) {
- return@withContext WavlakeZapOutcome.Failed("rng busy, retry")
- }
- }
- } else null
- val signerPub = identityPub
- ?: Secp256k1.get().pubkeyCreate(ephemeral!!).copyOfRange(1, 33).toHex()
- val zapReq = WavlakeZap.buildZapRequest(
- trackId = trackId,
- albumId = albumId,
- amountSats = sats,
- comment = comment,
- relays = nostr.relaysConfig(),
- signerPubkeyHex = signerPub,
- ).sign { digest ->
- if (ephemeral != null) Secp256k1.get().signSchnorr(digest, ephemeral, null).toHex()
- else keys.signDigest(digest)
- } ?: return@withContext WavlakeZapOutcome.Failed("Couldn't build the zap request.")
-
- // Best-effort publish so relays (and receipts) can see it.
- runCatching {
- nostr.ensureConnected()
- nostr.publishRaw(zapReq)
- }
-
- // Fixed endpoint only — never follow redirects off-host (SSRF guard).
- val invoice = runCatching {
- val url = WavlakeZap.invoiceUrl(sats, zapReq.toJson())
- val request = okhttp3.Request.Builder().url(url).build()
- if (request.url.host != WavlakeZap.ZAP_HOST || request.url.scheme != "https") {
- return@runCatching null
- }
- http.newCall(request).execute().use { r ->
- if (!r.isSuccessful) return@runCatching null
- val body = JSONObject(r.body?.string().orEmpty())
- if (body.optString("status") == "ERROR" ||
- body.optBoolean("success", true) == false
- ) {
- return@withContext WavlakeZapOutcome.Failed(
- body.optString("reason", body.optString("error", "Zap rejected.")),
- )
- }
- body.optString("pr").takeIf { it.isNotBlank() }
- }
- }.getOrNull() ?: return@withContext WavlakeZapOutcome.Failed("Couldn't reach the Lightning endpoint.")
- if (!WavlakeZap.isPlausibleInvoice(invoice)) {
- Log.w("Zap", "Implausible invoice from zap endpoint")
- return@withContext WavlakeZapOutcome.Failed("Invalid invoice returned.")
- }
- WavlakeZapOutcome.Invoice(invoice, sats, zapReq.id)
- }
-
- /**
- * Full official track zap: invoice, then pay through the connected
- * wallet, then wait for the receipt — success is reported only on
- * confirmation, like the address flow.
- */
- suspend fun zapWavlakeTrack(
- trackId: String,
- albumId: String,
- sats: Long,
- comment: String = "",
- ): WavlakeZapOutcome = withContext(Dispatchers.IO) {
- val invoice = when (val r = wavlakeTrackInvoice(trackId, albumId, sats, comment)) {
- is WavlakeZapOutcome.Invoice -> r
- is WavlakeZapOutcome.Sent -> return@withContext r
- is WavlakeZapOutcome.Failed -> return@withContext r
- }
- when (val pay = paymentService.payInvoiceDirect(invoice.bolt11, sats)) {
- is PaymentResult.Paid -> {
- nostr.waitForZapReceipt(
- requestId = invoice.requestId,
- targetPubkey = WavlakeZap.FEED_PUBKEY,
- timeoutMs = 10_000,
- )
- WavlakeZapOutcome.Sent
- }
- is PaymentResult.Failed -> WavlakeZapOutcome.Failed(pay.userFacing)
- else -> WavlakeZapOutcome.Failed("Unexpected wallet response.")
- }
- }
-}
diff --git a/app/src/main/java/com/rada/player/PlaybackManager.kt b/app/src/main/java/com/rada/player/PlaybackManager.kt
index d656776..30b2d35 100644
--- a/app/src/main/java/com/rada/player/PlaybackManager.kt
+++ b/app/src/main/java/com/rada/player/PlaybackManager.kt
@@ -2,11 +2,6 @@ package com.rada.player
import android.content.ComponentName
import android.content.Context
-import android.media.AudioDeviceCallback
-import android.media.AudioDeviceInfo
-import android.media.AudioManager
-import android.os.Handler
-import android.os.Looper
import android.os.SystemClock
import android.util.Log
import androidx.media3.common.MediaItem
@@ -67,50 +62,6 @@ class PlaybackManager(
private var reconnectJob: Job? = null
private var reconnectAttempt = 0
private var readyAtMs = 0L
- /**
- * Bluetooth A2DP route tracking: car stereos often drop A2DP while
- * keeping hands-free connected (or vanish without the noisy broadcast),
- * so engine-off doesn't reliably pause via ExoPlayer alone. When the
- * last BT audio output disappears mid-playback, pause like Spotify.
- * Needs no Bluetooth permissions — output-device observation only.
- */
- private val audioManager: AudioManager? =
- context.getSystemService(AudioManager::class.java)
- private var wasBtA2dpConnected = false
- private var deviceCallbackRegistered = false
- private val deviceCallback = object : AudioDeviceCallback() {
- override fun onAudioDevicesAdded(devices: Array) {
- updateBtRoute()
- }
-
- override fun onAudioDevicesRemoved(devices: Array) {
- val hadBt = wasBtA2dpConnected
- updateBtRoute()
- if (hadBt && !wasBtA2dpConnected && isPlaying) {
- Log.d(TAG, "Bluetooth audio route lost — pausing")
- try {
- controller?.pause()
- } catch (e: Exception) {
- Log.w(TAG, "Pause on BT loss failed", e)
- }
- }
- }
- }
-
- private fun updateBtRoute() {
- wasBtA2dpConnected = audioManager
- ?.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
- .orEmpty()
- .any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP }
- }
- /** Delayed ICY-reader-only restart (see startIcyReader listener). */
- private var icyRestartJob: Job? = null
- /**
- * True after an explicit user Stop. Guards the auto-reconnect path: a
- * late player error must never resurrect a stream the user stopped.
- * Cleared whenever new playback starts.
- */
- private var userStopped = false
var listener: PlaybackListener? = null
@@ -139,71 +90,6 @@ class PlaybackManager(
private val _lastStreamMetaFlow = MutableStateFlow(null)
val lastStreamMetaFlow: StateFlow = _lastStreamMetaFlow.asStateFlow()
- // ── Queue / repeat / shuffle / speed / sleep timer ─────────────────
- /** Playback speed, 0.5×–2.0×. Survives station and track changes. */
- private val _speedFlow = MutableStateFlow(1.0f)
- val speedFlow: StateFlow = _speedFlow.asStateFlow()
-
- private val _repeatModeFlow = MutableStateFlow(androidx.media3.common.Player.REPEAT_MODE_OFF)
- val repeatModeFlow: StateFlow = _repeatModeFlow.asStateFlow()
-
- private val _shuffleEnabledFlow = MutableStateFlow(false)
- val shuffleEnabledFlow: StateFlow = _shuffleEnabledFlow.asStateFlow()
-
- /** Milliseconds left on the sleep timer; 0 when off. */
- private val _sleepRemainingMsFlow = MutableStateFlow(0L)
- val sleepRemainingMsFlow: StateFlow = _sleepRemainingMsFlow.asStateFlow()
-
- /** Library song id of the current queue item (null when not queue-playing). */
- private val _currentLocalTrackIdFlow = MutableStateFlow(null)
- val currentLocalTrackIdFlow: StateFlow = _currentLocalTrackIdFlow.asStateFlow()
-
- private var sleepJob: Job? = null
-
- fun setSpeed(speed: Float) {
- val clamped = speed.coerceIn(0.5f, 2.0f)
- _speedFlow.value = clamped
- controller?.setPlaybackSpeed(clamped)
- }
-
- fun setRepeatMode(@androidx.media3.common.Player.RepeatMode mode: Int) { controller?.repeatMode = mode
- }
-
- fun setShuffleEnabled(enabled: Boolean) {
- controller?.shuffleModeEnabled = enabled
- }
-
- /** Start (or restart) the sleep timer; pauses playback when it fires. */
- fun startSleepTimer(minutes: Int) {
- sleepJob?.cancel()
- if (minutes <= 0) {
- _sleepRemainingMsFlow.value = 0L
- return
- }
- val totalMs = minutes * 60_000L
- val endAt = SystemClock.elapsedRealtime() + totalMs
- sleepJob = scope.launch {
- while (true) {
- val remaining = endAt - SystemClock.elapsedRealtime()
- if (remaining <= 0L) break
- _sleepRemainingMsFlow.value = remaining
- delay(1_000L)
- }
- _sleepRemainingMsFlow.value = 0L
- sleepJob = null
- // Pause rather than stop: the station stays loaded for when the
- // user wakes up. Controller calls are main-thread confined.
- withContext(Dispatchers.Main) { controller?.pause() }
- }
- }
-
- fun cancelSleepTimer() {
- sleepJob?.cancel()
- sleepJob = null
- _sleepRemainingMsFlow.value = 0L
- }
-
-
private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE)
val bufferStateFlow: StateFlow = _bufferStateFlow.asStateFlow()
@@ -230,19 +116,6 @@ class PlaybackManager(
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
saveSongUseCase = SaveSongUseCase(context, libraryRepository)
- if (!deviceCallbackRegistered) {
- updateBtRoute()
- try {
- audioManager?.registerAudioDeviceCallback(
- deviceCallback,
- Handler(Looper.getMainLooper()),
- )
- deviceCallbackRegistered = true
- } catch (e: Exception) {
- Log.w(TAG, "BT route tracking unavailable", e)
- }
- }
-
val sessionToken = SessionToken(
context,
ComponentName(context, PlaybackService::class.java)
@@ -255,13 +128,6 @@ class PlaybackManager(
}
fun disconnect() {
- if (deviceCallbackRegistered) {
- try {
- audioManager?.unregisterAudioDeviceCallback(deviceCallback)
- } catch (_: Exception) {
- }
- deviceCallbackRegistered = false
- }
stopAll()
scope.cancel()
controller?.removeListener(playerListener)
@@ -273,17 +139,12 @@ class PlaybackManager(
fun playStation(url: String, name: String) {
currentStationUrl = url
currentStationName = name
- userStopped = false
_currentStationUrlFlow.value = url
_currentStationNameFlow.value = name
_isLocalFlow.value = false
_localMediaFlow.value = null
_lastStreamMetaFlow.value = null
- _currentLocalTrackIdFlow.value = null
val ctrl = controller ?: return
- // A live stream has no queue: shuffle must not leak from local playback.
- ctrl.shuffleModeEnabled = false
- _shuffleEnabledFlow.value = false
stopAll()
initBuffer()
@@ -301,92 +162,46 @@ class PlaybackManager(
.build()
ctrl.setMediaItem(mediaItem)
- ctrl.setPlaybackSpeed(_speedFlow.value)
ctrl.prepare()
ctrl.play()
startIcyReader(url)
}
- fun playLocalFile(filePath: String, title: String, artist: String, id: Long? = null) {
- playLocalQueue(listOf(LocalTrack(id, filePath, title, artist)), startIndex = 0, shuffle = false)
- }
-
- /** A local library track for queue playback. `id` links UI state to the player. */
- data class LocalTrack(val id: Long?, val filePath: String, val title: String, val artist: String)
-
- /**
- * Plays a local queue (library songs / playlist). ExoPlayer handles
- * advance, shuffle order and repeat natively, so transport next/prev
- * and end-of-track behave like a real music player.
- */
- fun playLocalQueue(tracks: List, startIndex: Int, shuffle: Boolean) {
- if (tracks.isEmpty()) return
- userStopped = false
+ fun playLocalFile(filePath: String, title: String, artist: String) {
currentStationUrl = null
currentStationName = ""
+ // Publish so the shared NowPlaying bar shows library playback too.
+ _currentStationNameFlow.value = if (artist.isBlank()) title else "$title — $artist"
_currentStationUrlFlow.value = null
_isLocalFlow.value = true
- val first = tracks[startIndex.coerceIn(0, tracks.lastIndex)]
- _currentStationNameFlow.value =
- if (first.artist.isBlank()) first.title else "${first.title} — ${first.artist}"
- _localMediaFlow.value = first.title to first.artist
+ _localMediaFlow.value = title to artist
_lastStreamMetaFlow.value = null
val ctrl = controller ?: return
stopAll()
- val items = tracks.map { track ->
- MediaItem.Builder()
- .setUri(track.filePath)
- .setMediaId(track.id?.toString() ?: track.filePath)
- .setMediaMetadata(
- MediaMetadata.Builder()
- .setArtist(track.artist)
- .setTitle(track.title)
- .build()
- )
- .build()
- }
- ctrl.setMediaItems(items, startIndex.coerceIn(0, items.lastIndex), 0L)
- ctrl.shuffleModeEnabled = shuffle
- _shuffleEnabledFlow.value = shuffle
- ctrl.setPlaybackSpeed(_speedFlow.value)
+ val mediaItem = MediaItem.Builder()
+ .setUri(filePath)
+ .setMediaMetadata(
+ MediaMetadata.Builder()
+ .setArtist(artist)
+ .setTitle(title)
+ .build()
+ )
+ .build()
+
+ ctrl.setMediaItem(mediaItem)
ctrl.prepare()
ctrl.play()
}
- /** Transport next within the current queue (no-op for a single stream). */
- fun seekToNextItem() {
- controller?.let { if (it.mediaItemCount > 1) it.seekToNext() }
- }
-
- fun seekToPreviousItem() {
- controller?.let { if (it.mediaItemCount > 1) it.seekToPrevious() }
- }
-
fun togglePlayPause() {
val ctrl = controller ?: return
- if (ctrl.isPlaying) {
- ctrl.pause()
- return
- }
- // Resume-from-stop restarts the station fully: stop() tears down the
- // recorder (buffer IDLE, reader stopped), so a bare play() would
- // bring back audio with a dead save button. A normal pause (buffer
- // READY) resumes directly.
- val url = currentStationUrl
- if (!_isLocalFlow.value && url != null &&
- (bufferState == BufferState.IDLE || bufferState == BufferState.ERROR)
- ) {
- playStation(url, currentStationName)
- return
- }
- ctrl.play()
+ if (ctrl.isPlaying) ctrl.pause() else ctrl.play()
}
fun stop() {
- userStopped = true
stopAll()
controller?.stop()
isPlaying = false
@@ -406,22 +221,12 @@ class PlaybackManager(
updateSaveState(SaveState.SAVING, null)
scope.launch {
- // A throw anywhere below must still reset saveState (and surface),
- // otherwise every later tap hits the SAVING guard silently.
- val result = try {
- saveSongUseCase.save(
- buffer = buffer,
- metadataTimeline = metadataTimeline,
- currentMetadata = metadataTimeline.currentMetadata,
- stationName = currentStationName,
- )
- } catch (e: Exception) {
- Log.e(TAG, "Save failed", e)
- SaveSongUseCase.SaveResult(
- success = false,
- error = "Save failed: ${e.message ?: e.javaClass.simpleName}",
- )
- }
+ val result = saveSongUseCase.save(
+ buffer = buffer,
+ metadataTimeline = metadataTimeline,
+ currentMetadata = metadataTimeline.currentMetadata,
+ stationName = currentStationName,
+ )
withContext(Dispatchers.Main) {
lastSaveResult = result
@@ -496,34 +301,8 @@ class PlaybackManager(
override fun onError(error: String) {
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
- // otherwise go stale silently (frozen buffer, dead save).
- // Restart just the reader while a station is still wanted.
- // NOTE: `isPlaying` is the wrong signal here — it is false
- // while stalled/buffering, exactly when a restart is needed.
- // Real deaths go through the full reconnect path, and
- // stopAll() cancels this job.
- if (!userStopped && !_isLocalFlow.value &&
- icyRestartJob?.isActive != true
- ) {
- val url = currentStationUrl
- if (url != null) {
- icyRestartJob = runCatching {
- scope.launch {
- delay(ICY_RESTART_DELAY_MS)
- if (!userStopped && !_isLocalFlow.value &&
- currentStationUrl == url
- ) {
- Log.d(TAG, "Restarting ICY reader")
- startIcyReader(url)
- }
- icyRestartJob = null
- }
- }.getOrNull()
- }
- }
}
+
override fun onConnected(metaInterval: Int) {
Log.d(TAG, "ICY connected, meta interval: $metaInterval bytes")
}
@@ -533,8 +312,6 @@ class PlaybackManager(
}
private fun stopIcyReader() {
- icyRestartJob?.cancel()
- icyRestartJob = null
icyReader?.stop()
icyReader = null
}
@@ -557,27 +334,6 @@ class PlaybackManager(
listener?.onPlaybackStateChanged(playing)
}
- override fun onRepeatModeChanged(mode: Int) {
- _repeatModeFlow.value = mode
- }
-
- override fun onShuffleModeEnabledChanged(enabled: Boolean) {
- _shuffleEnabledFlow.value = enabled
- }
-
- /** Queue advanced (auto or manual): publish the new item's identity. */
- override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
- if (_isLocalFlow.value && mediaItem != null) {
- val title = mediaItem.mediaMetadata.title?.toString().orEmpty()
- val artist = mediaItem.mediaMetadata.artist?.toString().orEmpty()
- _localMediaFlow.value = title to artist
- _currentStationNameFlow.value =
- if (artist.isBlank()) title else "$title — $artist"
- val itemId = mediaItem.mediaId.toLongOrNull()
- _currentLocalTrackIdFlow.value = itemId
- }
- }
-
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> {
@@ -586,20 +342,11 @@ class PlaybackManager(
}
Player.STATE_BUFFERING -> listener?.onPlaybackStateChanged(false)
Player.STATE_IDLE -> listener?.onPlaybackStateChanged(false)
- Player.STATE_ENDED -> {
- // A finished track is not playing: without this the UI
- // keeps a stale pause icon over silence.
- isPlaying = false
- _isPlayingFlow.value = false
- listener?.onPlaybackStateChanged(false)
- }
+ Player.STATE_ENDED -> listener?.onPlaybackStateChanged(false)
}
}
override fun onPlayerError(error: PlaybackException) {
- // A late error for playback the user already stopped is stale
- // noise: the buffer is IDLE and no reconnect is wanted.
- if (userStopped) return
val description = describeError(error)
updateBufferState(BufferState.ERROR)
stopIcyReader()
@@ -628,9 +375,6 @@ class PlaybackManager(
private fun scheduleReconnect(): Long? {
val url = currentStationUrl ?: return null
- // Never resurrect playback the user explicitly stopped, or a queue
- // they have since replaced with local tracks.
- if (userStopped || _isLocalFlow.value) return null
if (reconnectJob?.isActive == true) return null
if (readyAtMs != 0L && SystemClock.elapsedRealtime() - readyAtMs > RECONNECT_STABLE_MS) {
@@ -644,23 +388,9 @@ class PlaybackManager(
reconnectJob = scope.launch {
delay(delayMs)
- // MediaController is application-thread confined: marshal the
- // retry onto Main. Calling playStation() here on Dispatchers.IO
- // used to crash the whole process with IllegalStateException on
- // the first stream drop instead of recovering from it.
- if (currentStationUrl == url && !userStopped && !_isLocalFlow.value) {
- reconnectJob = null
- try {
- withContext(Dispatchers.Main) {
- if (currentStationUrl == url && !userStopped && !_isLocalFlow.value) {
- playStation(url, currentStationName)
- }
- }
- } catch (e: Exception) {
- Log.w(TAG, "Reconnect failed", e)
- }
- } else {
+ if (currentStationUrl == url) {
reconnectJob = null
+ playStation(url, currentStationName)
}
}
return delayMs
@@ -671,6 +401,5 @@ class PlaybackManager(
private const val RECONNECT_BASE_DELAY_MS = 1000L
private const val RECONNECT_MAX_DELAY_MS = 30_000L
private const val RECONNECT_STABLE_MS = 10_000L
- private const val ICY_RESTART_DELAY_MS = 5_000L
}
}
diff --git a/app/src/main/java/com/rada/player/PlaybackService.kt b/app/src/main/java/com/rada/player/PlaybackService.kt
index a3d193f..764509c 100644
--- a/app/src/main/java/com/rada/player/PlaybackService.kt
+++ b/app/src/main/java/com/rada/player/PlaybackService.kt
@@ -3,40 +3,24 @@ package com.rada.player
import android.app.PendingIntent
import android.content.Intent
import androidx.annotation.OptIn
-import androidx.media3.common.AudioAttributes
-import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
-import dagger.hilt.android.AndroidEntryPoint
import com.rada.ui.MainActivity
-import javax.inject.Inject
+import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class PlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
- @Inject lateinit var playbackManager: PlaybackManager
-
@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()
- // Spotify contract: the stream is classified as media and the player
- // yields audio focus (pauses for calls, ducks for transient
- // notifications) and resumes when focus returns. Without this, phone
- // calls never pause the music and Bluetooth can't hand over to them.
- val audioAttributes = AudioAttributes.Builder()
- .setUsage(C.USAGE_MEDIA)
- .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
- .build()
- val player = ExoPlayer.Builder(this)
- .setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
- .setHandleAudioBecomingNoisy(true)
- .build()
+ val player = ExoPlayer.Builder(this).build()
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
@@ -54,21 +38,9 @@ class PlaybackService : MediaSessionService() {
return mediaSession
}
- /**
- * Closed means stopped: swiping the task away is an explicit kill, so
- * stop playback (audio + recorder) and drop the notification. Screen-off
- * pocket listening is unaffected — the task still exists then.
- */
override fun onTaskRemoved(rootIntent: Intent?) {
- try {
- playbackManager.stop()
- } catch (_: Exception) {
- try {
- mediaSession?.player?.pause()
- } catch (_: Exception) {
- // Nothing more we can do; still stop below.
- }
- } finally {
+ val player = mediaSession?.player
+ if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
stopSelf()
}
}
diff --git a/app/src/main/java/com/rada/recording/MetadataEmbedder.kt b/app/src/main/java/com/rada/recording/MetadataEmbedder.kt
index a3c1aef..e0b6915 100644
--- a/app/src/main/java/com/rada/recording/MetadataEmbedder.kt
+++ b/app/src/main/java/com/rada/recording/MetadataEmbedder.kt
@@ -38,10 +38,8 @@ class MetadataEmbedder(private val context: Context) {
// Store the jaudiotagger AudioFile reference (Bug #6).
audio = AudioFileIO.read(audioFile)
- // Extracted segments are raw MP3 frames with NO tag yet, and
- // getTag() returns null for them — the old NullPointerException.
- // getTagOrCreateAndSetDefault() creates an ID3v2 tag instead.
- val tag = audio.tagOrCreateAndSetDefault
+ // Set metadata fields on the stored reference's tag.
+ val tag = audio.tag
tag.setField(FieldKey.ARTIST, artist.ifEmpty { "Unknown Artist" })
tag.setField(FieldKey.TITLE, title.ifEmpty { "Unknown Title" })
tag.setField(FieldKey.ALBUM, stationName.ifEmpty { "SatsAmp" })
diff --git a/app/src/main/java/com/rada/recording/SaveSongUseCase.kt b/app/src/main/java/com/rada/recording/SaveSongUseCase.kt
index f017327..9e76f5f 100644
--- a/app/src/main/java/com/rada/recording/SaveSongUseCase.kt
+++ b/app/src/main/java/com/rada/recording/SaveSongUseCase.kt
@@ -47,21 +47,6 @@ class SaveSongUseCase(
)
}
- // Refuse duplicates before touching storage: the same artist+title is
- // already in the library (case-insensitive, trimmed).
- val dupArtist = boundaries.artist.trim()
- val dupTitle = boundaries.title.trim()
- if (dupArtist.isNotEmpty() && dupTitle.isNotEmpty()) {
- val existing = libraryRepository.getByArtistTitle(dupArtist, dupTitle)
- if (existing != null) {
- return SaveResult(
- success = false,
- songId = existing.id,
- error = "Already in your library: $dupTitle",
- )
- }
- }
-
// Check minimum length (at least 10 seconds at 128kbps = 160,000 bytes)
val minBytes = 160_000L
if (boundaries.durationBytes < minBytes) {
diff --git a/app/src/main/java/com/rada/ui/MainActivity.kt b/app/src/main/java/com/rada/ui/MainActivity.kt
index b4fd011..1260ba3 100644
--- a/app/src/main/java/com/rada/ui/MainActivity.kt
+++ b/app/src/main/java/com/rada/ui/MainActivity.kt
@@ -1,19 +1,14 @@
package com.rada.ui
-import android.Manifest
-import android.content.pm.PackageManager
-import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.core.content.ContextCompat
import androidx.navigation.compose.rememberNavController
import com.rada.player.PlaybackManager
import com.rada.ui.navigation.NavGraph
@@ -27,22 +22,10 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var playbackManager: PlaybackManager
- /**
- * Playback controls + the way back into the app after swiping it away
- * live in the media notification — which Android 13+ hides until the
- * user grants POST_NOTIFICATIONS. Ask once per install on first launch;
- * a denial just means no notification (playback still works).
- */
- private val notificationPermission =
- registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
- getPreferences(MODE_PRIVATE).edit().putBoolean(PREF_NOTIF_ASKED, true).apply()
- }
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
playbackManager.connect()
- askNotificationPermissionOnce()
setContent {
SatsAmpTheme {
@@ -62,21 +45,4 @@ class MainActivity : ComponentActivity() {
playbackManager.disconnect()
super.onDestroy()
}
-
- private fun askNotificationPermissionOnce() {
- if (Build.VERSION.SDK_INT < 33) return
- val prefs = getPreferences(MODE_PRIVATE)
- if (prefs.getBoolean(PREF_NOTIF_ASKED, false)) return
- if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
- PackageManager.PERMISSION_GRANTED
- ) {
- prefs.edit().putBoolean(PREF_NOTIF_ASKED, true).apply()
- return
- }
- notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
- }
-
- companion object {
- private const val PREF_NOTIF_ASKED = "notif_asked"
- }
}
diff --git a/app/src/main/java/com/rada/ui/components/EqualizerBars.kt b/app/src/main/java/com/rada/ui/components/EqualizerBars.kt
deleted file mode 100644
index 0990484..0000000
--- a/app/src/main/java/com/rada/ui/components/EqualizerBars.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-package com.rada.ui.components
-
-import androidx.compose.animation.core.RepeatMode
-import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.infiniteRepeatable
-import androidx.compose.animation.core.rememberInfiniteTransition
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.Canvas
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.size
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.CornerRadius
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.dp
-import com.rada.ui.theme.LocalReduceMotion
-
-/**
- * The three-bar "now playing" indicator — the compact signal that a row is
- * the active source (Spotify's equalizer). Bars pulse out of phase; paused
- * freezes them mid-height so the shape still reads as "the equalizer", and
- * reduce-motion renders a static, even-bar version.
- */
-@Composable
-fun EqualizerBars(
- active: Boolean,
- color: Color,
- modifier: Modifier = Modifier,
- barWidth: Dp = 2.5.dp,
-) {
- val reduce = LocalReduceMotion.current
- val transition = rememberInfiniteTransition(label = "eq")
- // Out-of-phase sine-ish pulses; different durations keep bars unsynced.
- val b1 by transition.animateFloat(
- initialValue = 0.25f, targetValue = 1f,
- animationSpec = infiniteRepeatable(
- animation = tween(520), repeatMode = RepeatMode.Reverse,
- ), label = "b1",
- )
- val b2 by transition.animateFloat(
- initialValue = 1f, targetValue = 0.3f,
- animationSpec = infiniteRepeatable(
- animation = tween(660), repeatMode = RepeatMode.Reverse,
- ), label = "b2",
- )
- val b3 by transition.animateFloat(
- initialValue = 0.45f, targetValue = 1f,
- animationSpec = infiniteRepeatable(
- animation = tween(440), repeatMode = RepeatMode.Reverse,
- ), label = "b3",
- )
- // Frozen heights for the paused pose (mid-range, uneven = still "eq").
- val h1 = if (reduce || !active) 0.6f else b1
- val h2 = if (reduce || !active) 1f else b2
- val h3 = if (reduce || !active) 0.45f else b3
-
- Canvas(modifier = modifier.size(14.dp)) {
- val gap = this.size.width / 8f
- val w = barWidth.toPx()
- val totalW = w * 3 + gap * 2
- var x = (this.size.width - totalW) / 2f
- val r = CornerRadius(w / 2f, w / 2f)
- for (h in floatArrayOf(h1, h2, h3)) {
- val barH = this.size.height * h
- drawRoundRect(
- color = color,
- topLeft = Offset(x, this.size.height - barH),
- size = Size(w, barH),
- cornerRadius = r,
- )
- x += w + gap
- }
- }
-}
diff --git a/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt b/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt
deleted file mode 100644
index 82227ef..0000000
--- a/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt
+++ /dev/null
@@ -1,381 +0,0 @@
-package com.rada.ui.decentralized
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Bolt
-import androidx.compose.material.icons.filled.ContentCopy
-import androidx.compose.material.icons.filled.Check
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import android.app.Activity
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.platform.LocalClipboardManager
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.hilt.navigation.compose.hiltViewModel
-import com.rada.ui.theme.LocalTokens
-import com.rada.ui.theme.SatsAmpGlass
-
-/**
- * Decentralized settings sheet — progressive disclosure per the design:
- * listeners see "identity + wallet", advanced rows (relays, profile) sit
- * behind a toggle. All state via [DecentralizedViewModel].
- */
-@Composable
-fun DecentralizedSheet(
- onDismiss: () -> Unit,
- viewModel: DecentralizedViewModel = hiltViewModel(),
-) {
- val tokens = LocalTokens.current
- val npub by viewModel.npub.collectAsState()
- val walletConnected by viewModel.walletConnected.collectAsState()
- val relays by viewModel.relays.collectAsState()
- val message by viewModel.message.collectAsState()
- val busy by viewModel.busy.collectAsState()
- val clipboard = LocalClipboardManager.current
-
- val signerLauncher = rememberLauncherForActivityResult(
- ActivityResultContracts.StartActivityForResult(),
- ) { res ->
- if (res.resultCode != Activity.RESULT_OK) {
- viewModel.onSignerError()
- } else {
- val rejected = res.data?.getBooleanExtra("rejected", false) == true
- viewModel.onSignerResult(
- res.data?.getStringExtra("result"),
- res.data?.getStringExtra("package"),
- rejected,
- )
- }
- }
-
- var showAdvanced by remember { mutableStateOf(false) }
- var showImport by remember { mutableStateOf(false) }
- var showPubkeyImport by remember { mutableStateOf(false) }
- var nsecInput by remember { mutableStateOf("") }
- var pubkeyInput by remember { mutableStateOf("") }
- var nwcInput by remember { mutableStateOf("") }
- var nameInput by remember { mutableStateOf("") }
- var aboutInput by remember { mutableStateOf("") }
-
- AlertDialog(
- onDismissRequest = onDismiss,
- containerColor = tokens.panel,
- titleContentColor = tokens.text,
- shape = RoundedCornerShape(SatsAmpGlass.rCard),
- title = {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText)
- Spacer(Modifier.width(8.dp))
- Text("Nostr", fontWeight = FontWeight.Bold)
- }
- },
- text = {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- // ── Nostr identity ────────────────────────────────────
- SectionLabel("Nostr identity")
- val pk = npub
- if (pk == null) {
- Text(
- "Optional. Gives you a profile, follows, reactions and zaps. Everything else in SatsAmp works without it.",
- style = MaterialTheme.typography.bodySmall,
- color = tokens.textSecondary,
- )
- // Always shown: if no signer is installed the launch fails
- // and the user gets a clear message instead of a missing button.
- Button(
- onClick = {
- runCatching {
- signerLauncher.launch(viewModel.amberLoginIntent())
- }.onFailure {
- if (viewModel.isSignerInstalled) viewModel.onSignerError()
- else viewModel.onSignerNotFound()
- }
- },
- colors = ButtonDefaults.buttonColors(
- containerColor = tokens.accentFill,
- contentColor = tokens.accentOnFill,
- ),
- shape = RoundedCornerShape(SatsAmpGlass.rInput),
- modifier = Modifier.fillMaxWidth(),
- ) { Text("Login with Amber") }
- if (!viewModel.isSignerInstalled) {
- Text(
- "No signer app detected — install Amber on this phone, or import your npub below.",
- style = MaterialTheme.typography.bodySmall,
- color = tokens.textSecondary,
- )
- }
- Button(
- onClick = { viewModel.createIdentity() },
- colors = ButtonDefaults.buttonColors(
- containerColor = tokens.accentFill,
- contentColor = tokens.accentOnFill,
- ),
- shape = RoundedCornerShape(SatsAmpGlass.rInput),
- modifier = Modifier.fillMaxWidth(),
- ) { Text("Create Nostr identity") }
- TextButton(onClick = { showImport = !showImport }) {
- Text("Import existing nsec", color = tokens.accentText)
- }
- TextButton(onClick = { showPubkeyImport = !showPubkeyImport }) {
- Text("Import pubkey (e.g. from Amber)", color = tokens.accentText)
- }
- if (showImport) {
- OutlinedTextField(
- value = nsecInput,
- onValueChange = { nsecInput = it },
- placeholder = { Text("nsec… (stored encrypted)") },
- singleLine = true,
- colors = fieldColors(tokens),
- modifier = Modifier.fillMaxWidth(),
- )
- TextButton(
- onClick = { viewModel.importNsec(nsecInput) },
- enabled = nsecInput.isNotBlank(),
- ) { Text("Import") }
- }
- if (showPubkeyImport) {
- OutlinedTextField(
- value = pubkeyInput,
- onValueChange = { pubkeyInput = it },
- placeholder = { Text("npub1… (from Amber or another wallet)") },
- singleLine = true,
- colors = fieldColors(tokens),
- modifier = Modifier.fillMaxWidth(),
- )
- TextButton(
- onClick = {
- if (pubkeyInput.isNotBlank()) {
- viewModel.importPubkey(pubkeyInput)
- pubkeyInput = ""
- }
- },
- enabled = pubkeyInput.isNotBlank(),
- ) { Text("Import pubkey") }
- }
- } else {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(12.dp))
- .background(tokens.glassFillStrong)
- .border(1.dp, tokens.border, RoundedCornerShape(12.dp))
- .clickable { clipboard.setText(AnnotatedString(pk)) }
- .padding(horizontal = 12.dp, vertical = 10.dp),
- ) {
- Text(
- pk,
- style = MaterialTheme.typography.bodySmall,
- color = tokens.text,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.weight(1f),
- )
- Icon(
- Icons.Default.ContentCopy, contentDescription = "Copy npub",
- tint = tokens.textSecondary,
- modifier = Modifier.size(16.dp),
- )
- }
- Text(
- "tap to copy",
- style = MaterialTheme.typography.labelSmall,
- color = tokens.textMuted,
- )
- TextButton(onClick = { viewModel.signOut() }) {
- Text("Sign out", color = tokens.textSecondary)
- }
- }
-
- Spacer(Modifier.height(4.dp))
-
- // ── Wallet ────────────────────────────────────────────
- SectionLabel("Lightning wallet")
- if (!walletConnected) {
- Text(
- "Connect a NIP-47 wallet (Alby Hub, Coinos, etc.) with its pairing string to zap and tip artists.",
- style = MaterialTheme.typography.bodySmall,
- color = tokens.textSecondary,
- )
- OutlinedTextField(
- value = nwcInput,
- onValueChange = { nwcInput = it },
- placeholder = { Text("nostr+walletconnect://…") },
- singleLine = true,
- colors = fieldColors(tokens),
- modifier = Modifier.fillMaxWidth(),
- )
- Button(
- onClick = { viewModel.connectWallet(nwcInput) },
- enabled = nwcInput.isNotBlank() && busy.not(),
- colors = ButtonDefaults.buttonColors(
- containerColor = tokens.accentFill,
- contentColor = tokens.accentOnFill,
- ),
- shape = RoundedCornerShape(SatsAmpGlass.rInput),
- modifier = Modifier.fillMaxWidth(),
- ) {
- if (busy) CircularProgressIndicator(
- modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
- color = tokens.accentOnFill,
- ) else Text("Connect wallet")
- }
- } else {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(12.dp))
- .background(tokens.accentSoft)
- .padding(horizontal = 12.dp, vertical = 10.dp),
- ) {
- Icon(Icons.Default.Check, contentDescription = null,
- tint = tokens.accentText, modifier = Modifier.size(16.dp))
- Spacer(Modifier.width(8.dp))
- Text("Wallet connected", color = tokens.accentText,
- fontWeight = FontWeight.SemiBold,
- modifier = Modifier.weight(1f))
- TextButton(onClick = { viewModel.disconnectWallet() }) {
- Text("Disconnect", color = tokens.textSecondary)
- }
- }
- }
-
- Spacer(Modifier.height(4.dp))
-
- // ── Advanced ──────────────────────────────────────────
- TextButton(onClick = { showAdvanced = !showAdvanced }) {
- Text(
- if (showAdvanced) "Hide advanced" else "Advanced: relays & profile",
- color = tokens.accentText,
- )
- }
- if (showAdvanced) {
- SectionLabel("Relays")
- relays.forEach { r ->
- Text(
- r,
- style = MaterialTheme.typography.bodySmall,
- color = tokens.textSecondary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- TextButton(
- onClick = {
- viewModel.setRelays(
- listOf("wss://relay.damus.io", "wss://relay.primal.net", "wss://nos.lol"),
- )
- },
- ) { Text("Reset to defaults", color = tokens.textSecondary) }
-
- SectionLabel("Publish profile")
- OutlinedTextField(
- value = nameInput,
- onValueChange = { nameInput = it },
- placeholder = { Text("Display name") },
- singleLine = true,
- colors = fieldColors(tokens),
- modifier = Modifier.fillMaxWidth(),
- )
- OutlinedTextField(
- value = aboutInput,
- onValueChange = { aboutInput = it },
- placeholder = { Text("About (optional)") },
- singleLine = true,
- colors = fieldColors(tokens),
- modifier = Modifier.fillMaxWidth(),
- )
- Button(
- onClick = { viewModel.publishProfile(nameInput, aboutInput) },
- enabled = nameInput.isNotBlank() && busy.not(),
- colors = ButtonDefaults.buttonColors(
- containerColor = tokens.accentFill,
- contentColor = tokens.accentOnFill,
- ),
- shape = RoundedCornerShape(SatsAmpGlass.rInput),
- modifier = Modifier.fillMaxWidth(),
- ) { Text("Publish to relays") }
- }
-
- message?.let {
- Text(
- it,
- style = MaterialTheme.typography.bodySmall,
- color = tokens.accentText,
- )
- TextButton(onClick = { viewModel.consumeMessage() }) {
- Text("Dismiss", color = tokens.textMuted)
- }
- }
- }
- },
- confirmButton = {
- TextButton(onClick = onDismiss) { Text("Close", color = tokens.accentText) }
- },
- )
-}
-
-@Composable
-private fun SectionLabel(text: String) {
- Text(
- text.uppercase(),
- style = MaterialTheme.typography.labelMedium,
- fontWeight = FontWeight.Bold,
- color = LocalTokens.current.textMuted,
- )
-}
-
-@Composable
-private fun fieldColors(tokens: com.rada.ui.theme.UiTokens) =
- OutlinedTextFieldDefaults.colors(
- focusedBorderColor = tokens.accentText,
- unfocusedBorderColor = tokens.borderInteractive,
- focusedTextColor = tokens.text,
- unfocusedTextColor = tokens.text,
- cursorColor = tokens.accentText,
- focusedPlaceholderColor = tokens.textMuted,
- unfocusedPlaceholderColor = tokens.textMuted,
- )
diff --git a/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt b/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt
deleted file mode 100644
index a50adb8..0000000
--- a/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt
+++ /dev/null
@@ -1,140 +0,0 @@
-package com.rada.ui.decentralized
-
-import android.content.Intent
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.rada.nostr.NostrKeys
-import com.rada.nostr.NostrService
-import com.rada.payments.NwcProvider
-import com.rada.payments.PaymentService
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.stateIn
-
-@HiltViewModel
-class DecentralizedViewModel @Inject constructor(
- private val keys: NostrKeys,
- private val nostr: NostrService,
- private val nwc: NwcProvider,
- val paymentService: PaymentService,
-) : ViewModel() {
-
- /** NIP-19 npub for display/share (never raw hex). */
- val npub: kotlinx.coroutines.flow.StateFlow =
- keys.pubkey
- .map { hex -> hex?.let { com.rada.nostr.Nip19.npub(it) } }
- .stateIn(
- viewModelScope,
- kotlinx.coroutines.flow.SharingStarted.Eagerly,
- keys.npub(),
- )
- val nostrEnabled = MutableStateFlow(nostr.enabled)
- val walletConnected = MutableStateFlow(nwc.connected)
- val message = MutableStateFlow(null)
- val busy = MutableStateFlow(false)
- val isSignerInstalled: Boolean get() = keys.isExternalSignerInstalled()
- val signerPackage: String? get() = keys.signerPackage
-
- private val _relays = MutableStateFlow(nostr.relaysConfig())
- val relays: StateFlow> = _relays.asStateFlow()
-
- fun createIdentity() {
- runCatching { keys.createIdentity() }
- .onSuccess {
- nostr.setEnabled(true)
- nostrEnabled.value = true
- message.value = "Nostr identity created ✅"
- }
- .onFailure { message.value = "Couldn't create identity: ${it.message}" }
- }
-
- fun importNsec(nsec: String) {
- if (keys.importNsec(nsec)) {
- nostr.setEnabled(true)
- nostrEnabled.value = true
- message.value = "Identity imported ✅"
- } else {
- message.value = "That doesn't look like a valid nsec."
- }
- }
-
- fun importPubkey(npub: String) {
- if (keys.importPubkey(npub)) {
- nostr.setEnabled(true)
- nostrEnabled.value = true
- message.value = "Pubkey imported ✅ — relays connected for profile lookups"
- } else {
- message.value = "That doesn't look like a valid npub."
- }
- }
-
- fun amberLoginIntent(): Intent = keys.buildGetPubkeyIntent()
-
- /** Handle the NIP-55 `get_public_key` activity result. */
- fun onSignerResult(resultHex: String?, signerPkg: String?, rejected: Boolean): Boolean {
- if (rejected) {
- message.value = "Sign-in rejected in the signer app."
- return false
- }
- if (resultHex == null || !keys.importHexPubkey(resultHex)) {
- message.value = "Signer didn't return a valid pubkey — try again."
- return false
- }
- if (signerPkg != null) keys.saveSignerPackage(signerPkg)
- nostr.setEnabled(true)
- nostrEnabled.value = true
- message.value = "Connected via external signer ✅"
- return true
- }
-
- fun onSignerError() {
- message.value = "Signer app failed to respond — try again."
- }
-
- fun onSignerNotFound() {
- message.value = "No signer app found — install Amber (F-Droid / Zapstore / Play), then try again."
- }
-
- fun signOut() {
- keys.clearIdentity()
- nostr.setEnabled(false)
- nostrEnabled.value = false
- message.value = "Signed out — identity erased from this device."
- }
-
- fun publishProfile(name: String, about: String) {
- busy.value = true
- val ev = nostr.publishProfile(name, about, "")
- busy.value = false
- message.value = if (ev != null)
- "Profile published to ${nostr.relaysConfig().size} relays"
- else "Publish failed — check relays are reachable."
- }
-
- fun setRelays(list: List) {
- nostr.setRelays(list)
- _relays.value = nostr.relaysConfig()
- message.value = "Relays updated"
- }
-
- fun connectWallet(uri: String) {
- if (nwc.connect(uri)) {
- walletConnected.value = true
- message.value = "Wallet connected via Nostr Wallet Connect ⚡"
- } else {
- message.value = "That pairing string isn't valid (nostr+walletconnect://…)."
- }
- }
-
- fun disconnectWallet() {
- nwc.disconnect()
- walletConnected.value = false
- message.value = "Wallet disconnected"
- }
-
- fun consumeMessage() { message.value = null }
-}
diff --git a/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt b/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt
deleted file mode 100644
index 116a0e8..0000000
--- a/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt
+++ /dev/null
@@ -1,227 +0,0 @@
-package com.rada.ui.decentralized
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Bolt
-import androidx.compose.material.icons.filled.ContentCopy
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.FilterChipDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalClipboardManager
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.hilt.navigation.compose.hiltViewModel
-import com.rada.payments.hasValidLightningAddress
-import com.rada.ui.theme.LocalTokens
-
-/**
- * "⚡ Support" — the whole listener-facing payment surface. Presets +
- * optional comment + one address field (remembered). Advanced wiring
- * (NWC, relays) lives in the Decentralized sheet, not here.
- */
-@Composable
-fun TipSheet(
- artistName: String,
- trackTitle: String?,
- onDismiss: () -> Unit,
- initialAddress: String = "",
- /** Wavlake artist id — a successful zap remembers its address for next time. */
- artistId: String? = null,
- viewModel: TipViewModel = hiltViewModel(),
-) {
- val tokens = LocalTokens.current
- val status by viewModel.status.collectAsState()
- val sending by viewModel.sending.collectAsState()
- val hasWallet by viewModel.hasWallet.collectAsState()
- val recents by viewModel.recentAddresses.collectAsState()
-
- var amount by remember { mutableStateOf(100L) }
- var customAmount by remember { mutableStateOf("") }
- var address by remember(initialAddress) {
- mutableStateOf(
- initialAddress.ifBlank { recents.firstOrNull() ?: "" },
- )
- }
- var comment by remember { mutableStateOf("") }
- val finalSats = customAmount.toLongOrNull()?.takeIf { it > 0 } ?: amount
-
- AlertDialog(
- onDismissRequest = onDismiss,
- containerColor = tokens.panel,
- titleContentColor = tokens.text,
- shape = RoundedCornerShape(20.dp),
- title = {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText)
- Spacer(Modifier.size(8.dp))
- Text("Support $artistName", fontWeight = FontWeight.Bold)
- }
- },
- text = {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- trackTitle?.takeIf { it.isNotBlank() }?.let {
- Text(
- "playing · $it",
- style = MaterialTheme.typography.bodySmall,
- color = tokens.textMuted,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- listOf(21L, 100L, 500L, 1000L).forEach { preset ->
- FilterChip(
- selected = customAmount.isBlank() && amount == preset,
- onClick = { amount = preset; customAmount = "" },
- label = { Text("$preset") },
- colors = FilterChipDefaults.filterChipColors(
- selectedContainerColor = tokens.accentSoft,
- selectedLabelColor = tokens.accentText,
- labelColor = tokens.textSecondary,
- ),
- )
- }
- }
- OutlinedTextField(
- value = customAmount,
- onValueChange = { customAmount = it.filter(Char::isDigit) },
- placeholder = { Text("Custom amount in sats") },
- singleLine = true,
- suffix = { Text("sats", color = tokens.textMuted) },
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = tokens.accentText,
- unfocusedBorderColor = tokens.borderInteractive,
- focusedTextColor = tokens.text,
- unfocusedTextColor = tokens.text,
- cursorColor = tokens.accentText,
- focusedPlaceholderColor = tokens.textMuted,
- unfocusedPlaceholderColor = tokens.textMuted,
- ),
- modifier = Modifier.fillMaxWidth(),
- )
- OutlinedTextField(
- value = address,
- onValueChange = { address = it },
- placeholder = { Text("name@domain (Lightning address)") },
- singleLine = true,
- // Copy path needs no wallet: a valid address can always
- // be taken to any wallet app. Shown only for valid
- // addresses — never a dead or decorative button.
- trailingIcon = {
- if (hasValidLightningAddress(address)) {
- val clipboard = LocalClipboardManager.current
- androidx.compose.material3.IconButton(onClick = {
- clipboard.setText(AnnotatedString(address.trim()))
- // User-attested + syntactically valid: remembering
- // here closes the wallet-less loop (paste once
- // from the official page → bolt forever).
- if (!artistId.isNullOrBlank()) {
- viewModel.rememberArtistAddress(artistId, address)
- }
- viewModel.status.value =
- "Address copied — paste it in any wallet app."
- }) {
- Icon(
- Icons.Default.ContentCopy,
- contentDescription = "Copy Lightning address",
- tint = tokens.accentText,
- )
- }
- }
- },
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = tokens.accentText,
- unfocusedBorderColor = tokens.borderInteractive,
- focusedTextColor = tokens.text,
- unfocusedTextColor = tokens.text,
- cursorColor = tokens.accentText,
- focusedPlaceholderColor = tokens.textMuted,
- unfocusedPlaceholderColor = tokens.textMuted,
- ),
- modifier = Modifier.fillMaxWidth(),
- )
- OutlinedTextField(
- value = comment,
- onValueChange = { comment = it },
- placeholder = { Text("Message (optional)") },
- singleLine = true,
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = tokens.accentText,
- unfocusedBorderColor = tokens.borderInteractive,
- focusedTextColor = tokens.text,
- unfocusedTextColor = tokens.text,
- cursorColor = tokens.accentText,
- focusedPlaceholderColor = tokens.textMuted,
- unfocusedPlaceholderColor = tokens.textMuted,
- ),
- modifier = Modifier.fillMaxWidth(),
- )
- status?.let {
- Text(it, style = MaterialTheme.typography.bodySmall, color = tokens.accentText)
- }
- }
- },
- confirmButton = {
- Button(
- onClick = { viewModel.tip(address, finalSats, artistName, trackTitle, artistId) },
- enabled = !sending && address.contains("@") && finalSats > 0,
- colors = ButtonDefaults.buttonColors(
- containerColor = tokens.accentFill,
- contentColor = tokens.accentOnFill,
- ),
- shape = RoundedCornerShape(14.dp),
- ) {
- if (sending) {
- CircularProgressIndicator(
- modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
- color = tokens.accentOnFill,
- )
- } else {
- Text("Send $finalSats sats")
- }
- }
- },
- dismissButton = {
- if (!hasWallet) {
- Text(
- "No wallet connected — copy the address\nabove and pay from any wallet app.",
- style = MaterialTheme.typography.labelSmall,
- color = tokens.textMuted,
- )
- }
- },
- )
-}
diff --git a/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt b/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt
deleted file mode 100644
index 93e6176..0000000
--- a/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-package com.rada.ui.decentralized
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import android.content.Context
-import com.rada.data.wavlake.WavlakeRepository
-import com.rada.payments.PaymentResult
-import com.rada.payments.PaymentService
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
-
-@HiltViewModel
-class TipViewModel @Inject constructor(
- @ApplicationContext private val context: Context,
- private val paymentService: PaymentService,
- private val wavlake: WavlakeRepository,
-) : ViewModel() {
-
- private val prefs = context.getSharedPreferences("tips", Context.MODE_PRIVATE)
-
- val status = MutableStateFlow(null)
- val sending = MutableStateFlow(false)
- val hasWallet = MutableStateFlow(paymentService.hasAnyWallet)
-
- /** Recently used lightning addresses (most recent first, max 8). */
- private val _recentAddresses = MutableStateFlow(
- prefs.getString("recent", "")!!.split("\n").filter { it.contains("@") },
- )
- val recentAddresses: StateFlow> = _recentAddresses.asStateFlow()
-
- fun tip(
- address: String,
- sats: Long,
- artistName: String,
- trackTitle: String?,
- artistId: String? = null,
- ) {
- val clean = address.trim().removePrefix("lightning:")
- if (!clean.contains("@")) {
- status.value = "Enter a name@domain Lightning address."
- return
- }
- sending.value = true
- status.value = null
- viewModelScope.launch {
- when (val r = paymentService.tipArtist(clean, sats, artistName, trackTitle)) {
- is PaymentResult.Paid -> {
- status.value = "⚡ $sats sats sent to $artistName"
- _recentAddresses.value =
- (listOf(clean) + _recentAddresses.value.filter { it != clean })
- .distinct().take(8)
- prefs.edit()
- .putString("recent", _recentAddresses.value.joinToString("\n"))
- .apply()
- // Remember validated addresses per artist: the bolt can
- // appear (prefilled) from the next encounter on.
- if (!artistId.isNullOrBlank()) {
- wavlake.rememberAddress(artistId, clean)
- }
- }
- is PaymentResult.Failed -> status.value = r.userFacing
- else -> status.value = "Invoice created."
- }
- sending.value = false
- hasWallet.value = paymentService.hasAnyWallet
- }
- }
-
- /** Stores a user-attested address so the bolt can appear next time. */
- fun rememberArtistAddress(artistId: String, address: String) {
- wavlake.rememberAddress(artistId, address)
- }
-}
diff --git a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt
deleted file mode 100644
index 2972df0..0000000
--- a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt
+++ /dev/null
@@ -1,1149 +0,0 @@
-package com.rada.ui.discover
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-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.Search
-import androidx.compose.material.icons.filled.Tune
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.FilterChipDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Slider
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.SnackbarHostState
-import androidx.compose.material3.Switch
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.snapshotFlow
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-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.hilt.navigation.compose.hiltViewModel
-import com.rada.data.wavlake.MatchingMode
-import com.rada.data.wavlake.WavlakeSearchHit
-import com.rada.data.wavlake.WavlakeStationSettings
-import com.rada.domain.model.WavlakeTrack
-import com.rada.payments.hasValidLightningAddress
-import com.rada.ui.components.AppHomeButton
-import com.rada.ui.components.EqualizerBars
-import com.rada.ui.theme.LocalTokens
-import com.rada.ui.theme.SatsAmpGlass
-import kotlinx.coroutines.flow.first
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun DiscoverScreen(
- onBack: () -> Unit,
- onHome: () -> Unit = {},
- viewModel: WavlakeViewModel = hiltViewModel(),
-) {
- val searchQuery by viewModel.searchQuery.collectAsState()
- val searchState by viewModel.searchState.collectAsState()
- val rankings by viewModel.rankings.collectAsState()
- val rankingsGenre by viewModel.rankingsGenre.collectAsState()
- val rankingsLoading by viewModel.rankingsLoading.collectAsState()
- val rankingsError by viewModel.rankingsError.collectAsState()
- val addresses by viewModel.addresses.collectAsState()
- val selectedArtistId by viewModel.selectedArtistId.collectAsState()
- val artistDetail by viewModel.artistDetail.collectAsState()
- val artistLoading by viewModel.artistLoading.collectAsState()
- val notice by viewModel.notice.collectAsState()
- val snackbarHostState = remember { SnackbarHostState() }
- var showSettings by remember { mutableStateOf(false) }
- var zapTarget by remember { mutableStateOf(null) }
- var trackZap by remember { mutableStateOf(null) }
- var pendingZapArtistId by remember { mutableStateOf(null) }
-
- LaunchedEffect(pendingZapArtistId) {
- if (pendingZapArtistId == null) return@LaunchedEffect
- val addr = snapshotFlow { addresses[pendingZapArtistId!!] }
- .first { it != null } ?: ""
- zapTarget = ZapTarget(
- pendingZapArtistId!!,
- "",
- null,
- addr,
- )
- pendingZapArtistId = null
- }
-
- LaunchedEffect(notice) {
- notice?.let {
- snackbarHostState.showSnackbar(it)
- viewModel.consumeNotice()
- }
- }
-
- if (showSettings) {
- WavlakeSettingsDialog(
- current = viewModel.settings(),
- onDismiss = { showSettings = false },
- onSave = {
- viewModel.saveSettings(it)
- showSettings = false
- },
- )
- }
-
- zapTarget?.let { target ->
- com.rada.ui.decentralized.TipSheet(
- artistName = target.artistName,
- trackTitle = target.trackTitle,
- initialAddress = target.address,
- artistId = target.artistId,
- onDismiss = { zapTarget = null },
- )
- }
-
- trackZap?.let { track ->
- WavlakeZapSheet(
- track = track,
- onDismiss = { trackZap = null },
- )
- }
-
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text(if (selectedArtistId != null) "Artist" else "Discover") },
- navigationIcon = {
- Row(horizontalArrangement = Arrangement.Start) {
- AppHomeButton(onClick = onHome)
- IconButton(onClick = {
- if (selectedArtistId != null) viewModel.closeArtist() else onBack()
- }) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
- }
- }
- },
- actions = {
- if (selectedArtistId == null) {
- IconButton(onClick = { showSettings = true }) {
- Icon(Icons.Default.Tune, contentDescription = "Wavlake settings")
- }
- }
- },
- colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
- )
- },
- containerColor = Color.Transparent,
- snackbarHost = { SnackbarHost(snackbarHostState) },
- ) { padding ->
- if (selectedArtistId != null) {
- ArtistDetailView(
- artistId = selectedArtistId!!,
- modifier = Modifier.padding(padding),
- viewModel = viewModel,
- )
- } else {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding),
- ) {
- OutlinedTextField(
- value = searchQuery,
- onValueChange = { viewModel.search(it) },
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp),
- placeholder = { Text("Search Wavlake artists, tracks…") },
- singleLine = true,
- leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
- colors = OutlinedTextFieldDefaults.colors(
- focusedContainerColor = LocalTokens.current.panel,
- unfocusedContainerColor = LocalTokens.current.panel,
- focusedBorderColor = LocalTokens.current.accentFill.copy(alpha = 0.5f),
- unfocusedBorderColor = LocalTokens.current.border,
- focusedTextColor = LocalTokens.current.text,
- unfocusedTextColor = LocalTokens.current.text,
- ),
- shape = RoundedCornerShape(12.dp),
- )
-
- when (val s = searchState) {
- WavlakeSearchState.Idle -> {
- GenreChips(
- genres = DISCOVER_GENRES,
- selected = rankingsGenre,
- onSelect = { viewModel.refreshRankings(it) },
- )
- RankingsList(
- title = if (rankingsGenre != null) "Top in ${rankingsGenre}" else "Top on Wavlake",
- tracks = rankings,
- loading = rankingsLoading,
- error = rankingsError,
- onRetry = { viewModel.refreshRankings(rankingsGenre) },
- viewModel = viewModel,
- onZap = { track, address ->
- if (address != null) {
- zapTarget = ZapTarget(
- track.artistId,
- track.artistName,
- track.title,
- address,
- )
- } else {
- trackZap = track
- }
- },
- )
- }
- WavlakeSearchState.Loading -> {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(24.dp),
- horizontalArrangement = Arrangement.Center,
- ) {
- CircularProgressIndicator(
- modifier = Modifier.size(22.dp),
- strokeWidth = 2.dp,
- color = LocalTokens.current.accentText,
- )
- }
- }
- is WavlakeSearchState.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)
- },
- onZap = {
- viewModel.resolveArtistAddress(hit.artist.id)
- pendingZapArtistId = hit.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(
- track = com.rada.domain.model.WavlakeTrack(
- id = hit.track.id,
- title = hit.track.title,
- artistId = hit.track.artistId,
- artistName = hit.track.artistName,
- genre = hit.track.genre,
- genres = hit.track.genres,
- artworkUrl = hit.track.artworkUrl,
- durationMs = hit.track.durationMs,
- audioUrl = null,
- artistNpubHex = null,
- ),
- showZap = true,
- onPlay = {
- viewModel.playSearchTrack(
- hit.track.id,
- hit.track.artistId,
- hit.track.title,
- )
- },
- onSave = {
- viewModel.saveSearchTrack(
- hit.track.id,
- hit.track.artistId,
- hit.track.title,
- )
- },
- onZap = { t ->
- addresses[t.artistId]?.let { addr ->
- zapTarget = ZapTarget(
- t.artistId,
- t.artistName,
- t.title,
- addr,
- )
- } ?: viewModel.resolveForZap(
- t.id, t.artistId, t.title,
- ) { full ->
- if (full != null) trackZap = full
- }
- },
- )
- }
- }
- }
- }
- is WavlakeSearchState.Error -> {
- Text(
- text = s.message,
- style = MaterialTheme.typography.bodyMedium,
- color = LocalTokens.current.textSecondary,
- modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
- )
- }
- }
- }
- }
- }
-}
-
-private data class ZapTarget(
- val artistId: String,
- val artistName: String,
- val trackTitle: String?,
- val address: String,
-)
-
-@Composable
-private fun GenreChips(
- genres: List,
- selected: String?,
- onSelect: (String?) -> Unit,
-) {
- LazyRow(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- contentPadding = androidx.compose.foundation.layout.PaddingValues(
- horizontal = 16.dp,
- vertical = 4.dp,
- ),
- ) {
- item(key = "chip-all") {
- GenreChip(label = "Top", selected = selected == null, onClick = { onSelect(null) })
- }
- items(genres, key = { "chip-$it" }) { genre ->
- GenreChip(label = genre, selected = selected == genre, onClick = { onSelect(genre) })
- }
- }
-}
-
-@Composable
-private fun GenreChip(label: String, selected: Boolean, onClick: () -> Unit) {
- FilterChip(
- selected = selected,
- onClick = onClick,
- label = { Text(label) },
- colors = FilterChipDefaults.filterChipColors(
- containerColor = LocalTokens.current.panel,
- labelColor = LocalTokens.current.textSecondary,
- selectedContainerColor = LocalTokens.current.accentFill.copy(alpha = 0.22f),
- selectedLabelColor = LocalTokens.current.accentText,
- ),
- border = FilterChipDefaults.filterChipBorder(
- borderColor = LocalTokens.current.border,
- selectedBorderColor = LocalTokens.current.accentFill,
- enabled = true,
- selected = selected,
- ),
- )
-}
-
-@Composable
-private fun RankingsList(
- title: String,
- tracks: List,
- loading: Boolean,
- error: String?,
- onRetry: () -> Unit,
- viewModel: WavlakeViewModel,
- onZap: (WavlakeTrack, String?) -> Unit,
-) {
- val addresses by viewModel.addresses.collectAsState()
- Column(modifier = Modifier.fillMaxSize()) {
- Text(
- text = title.uppercase(),
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.Bold,
- color = LocalTokens.current.accentText,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- )
- when {
- loading -> {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(24.dp),
- horizontalArrangement = Arrangement.Center,
- ) {
- CircularProgressIndicator(
- modifier = Modifier.size(22.dp),
- strokeWidth = 2.dp,
- color = LocalTokens.current.accentText,
- )
- }
- }
- error != null -> {
- Text(
- text = error,
- style = MaterialTheme.typography.bodyMedium,
- color = LocalTokens.current.textSecondary,
- modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp),
- )
- TextButton(onClick = onRetry) { Text("Retry") }
- }
- tracks.isEmpty() -> {
- Text(
- text = "Nothing here yet.",
- style = MaterialTheme.typography.bodyMedium,
- color = LocalTokens.current.textSecondary,
- modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp),
- )
- }
- else -> {
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- items(tracks, key = { it.id }, contentType = { "track" }) { track ->
- TrackRow(
- track = track,
- showZap = addresses[track.artistId] != null || track.isZapPayable,
- onPlay = { viewModel.playTrack(track) },
- onSave = { viewModel.saveTrack(track) },
- onZap = { t -> onZap(t, addresses[t.artistId]) },
- )
- }
- }
- }
- }
- }
-}
-
-/** Track row: glyph tile + title/artist + play + save + bolt (see callers). */
-@Composable
-fun TrackRow(
- track: WavlakeTrack,
- showZap: Boolean,
- onPlay: () -> Unit,
- onSave: () -> Unit,
- onZap: (WavlakeTrack) -> Unit,
-) {
- val tokens = LocalTokens.current
- Card(
- onClick = onPlay,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp),
- colors = CardDefaults.cardColors(containerColor = tokens.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(tokens.accentFill.copy(alpha = 0.15f)),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = track.artistName.firstOrNull()?.uppercase() ?: "♪",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = tokens.accentText,
- )
- }
- Spacer(modifier = Modifier.width(14.dp))
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = track.title,
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.SemiBold,
- color = tokens.text,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Text(
- text = listOfNotNull(
- track.artistName.takeIf { it.isNotBlank() },
- track.genre?.takeIf { it.isNotBlank() },
- ).joinToString(" · ").ifEmpty { "Wavlake" },
- style = MaterialTheme.typography.bodySmall,
- color = tokens.textSecondary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- // Bolt rule: address path when known, else the official
- // track-zap path when the track is payable. Never rendered
- // when neither holds.
- if (showZap) {
- IconButton(onClick = { onZap(track) }, modifier = Modifier.size(48.dp)) {
- Icon(
- Icons.Default.Bolt,
- contentDescription = "Zap ${track.artistName}",
- tint = tokens.accentFill,
- modifier = Modifier.size(22.dp),
- )
- }
- }
- IconButton(onClick = onPlay, modifier = Modifier.size(48.dp)) {
- Icon(
- Icons.Default.PlayArrow,
- contentDescription = "Play ${track.title}",
- tint = tokens.textSecondary,
- modifier = Modifier.size(22.dp),
- )
- }
- IconButton(onClick = onSave, modifier = Modifier.size(48.dp)) {
- Icon(
- Icons.Default.Add,
- contentDescription = "Save ${track.title} to Library",
- tint = tokens.textSecondary,
- modifier = Modifier.size(22.dp),
- )
- }
- }
- }
-}
-
-@Composable
-fun ArtistRow(
- name: String,
- genre: String?,
- imageUrl: String?,
- artistId: String? = null,
- address: String? = null,
- onClick: () -> Unit = {},
- onZap: () -> Unit = {},
-) {
- val tokens = LocalTokens.current
- val hasAddress = hasValidLightningAddress(address)
- Card(
- onClick = onClick,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp),
- colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong),
- shape = RoundedCornerShape(SatsAmpGlass.rInput),
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Box(
- modifier = Modifier
- .size(40.dp)
- .clip(CircleShape)
- .background(tokens.accentFill.copy(alpha = 0.15f)),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = name.firstOrNull()?.uppercase() ?: "?",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = tokens.accentText,
- )
- }
- Spacer(modifier = Modifier.width(14.dp))
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = name,
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.SemiBold,
- color = tokens.text,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Text(
- text = genre?.takeIf { it.isNotBlank() } ?: "Wavlake artist",
- style = MaterialTheme.typography.bodySmall,
- color = tokens.textSecondary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- if (hasAddress) {
- IconButton(onClick = onZap, modifier = Modifier.size(48.dp)) {
- Icon(
- Icons.Default.Bolt,
- contentDescription = "Zap $name",
- tint = tokens.accentFill,
- modifier = Modifier.size(22.dp),
- )
- }
- }
- }
- }
-}
-
-@Composable
-private fun AlbumRow(
- title: String,
- artist: String?,
- artworkUrl: String?,
- artistId: String? = null,
- address: String? = null,
- onClick: () -> Unit = {},
- onZap: () -> Unit = {},
-) {
- ArtistRow(name = title, genre = artist, imageUrl = artworkUrl, artistId = artistId, address = address, onClick = onClick, onZap = onZap)
-}
-
-@Composable
-fun ArtistDetailView(
- artistId: String,
- modifier: Modifier = Modifier,
- viewModel: WavlakeViewModel = hiltViewModel(),
-) {
- val detail by viewModel.artistDetail.collectAsState()
- val loading by viewModel.artistLoading.collectAsState()
- val addresses by viewModel.addresses.collectAsState()
- val tokens = LocalTokens.current
- // The single zap sheet, fed by whichever bolt was tapped (header or row).
- var zapAddress by remember { mutableStateOf(null) }
- zapAddress?.let { addr ->
- com.rada.ui.decentralized.TipSheet(
- artistName = detail?.artist?.name ?: "Artist",
- trackTitle = null,
- initialAddress = addr,
- artistId = artistId,
- onDismiss = { zapAddress = null },
- )
- }
-
- Column(modifier = modifier.fillMaxSize()) {
- if (loading && detail == null) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(24.dp),
- horizontalArrangement = Arrangement.Center,
- ) {
- CircularProgressIndicator(
- modifier = Modifier.size(22.dp),
- strokeWidth = 2.dp,
- color = tokens.accentText,
- )
- }
- return
- }
- val artist = detail?.artist ?: return
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Box(
- modifier = Modifier
- .size(72.dp)
- .clip(CircleShape)
- .background(tokens.accentFill.copy(alpha = 0.15f)),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- text = artist.name.firstOrNull()?.uppercase() ?: "?",
- style = MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
- color = tokens.accentText,
- )
- }
- Spacer(modifier = Modifier.width(16.dp))
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = artist.name,
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold,
- color = tokens.text,
- )
- Text(
- text = (artist.genres.ifEmpty { listOfNotNull(artist.genre) })
- .joinToString(" · ").ifEmpty { "Wavlake artist" },
- style = MaterialTheme.typography.bodySmall,
- color = tokens.textSecondary,
- )
- }
- // STRICT gate: no address, no bolt — nothing rendered at all.
- val headerAddress = addresses[artistId]
- if (headerAddress != null) {
- IconButton(
- onClick = { zapAddress = headerAddress },
- modifier = Modifier.size(48.dp),
- ) {
- Icon(
- Icons.Default.Bolt,
- contentDescription = "Zap ${artist.name}",
- tint = tokens.accentFill,
- modifier = Modifier.size(24.dp),
- )
- }
- }
- }
- if (!artist.bio.isNullOrBlank()) {
- Text(
- text = artist.bio,
- style = MaterialTheme.typography.bodyMedium,
- color = tokens.textSecondary,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
- )
- }
- val tracks = detail?.tracks.orEmpty()
- // Official Wavlake page (track URL is an official API field): the
- // one-tap path to zap artists whose Lightning Address the catalog
- // doesn't expose. External browser — no address invented here.
- val wavlakeUrl = tracks.firstNotNullOfOrNull { it.sourceUrl }
- if (wavlakeUrl != null) {
- val context = androidx.compose.ui.platform.LocalContext.current
- TextButton(onClick = {
- runCatching {
- context.startActivity(
- android.content.Intent(
- android.content.Intent.ACTION_VIEW,
- android.net.Uri.parse(wavlakeUrl),
- ),
- )
- }
- }) {
- Text(
- "View on wavlake.com",
- modifier = Modifier.padding(horizontal = 8.dp),
- )
- }
- }
- if (tracks.isEmpty() && !loading) {
- Text(
- text = "No tracks found for this artist.",
- style = MaterialTheme.typography.bodyMedium,
- color = tokens.textSecondary,
- modifier = Modifier.padding(horizontal = 16.dp),
- )
- } else {
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- items(tracks, key = { it.id }, contentType = { "track" }) { track ->
- var detailTrackZap by remember(track.id) {
- mutableStateOf(null)
- }
- TrackRow(
- track = track,
- showZap = addresses[track.artistId] != null || track.isZapPayable,
- onPlay = { viewModel.playTrack(track) },
- onSave = { viewModel.saveTrack(track) },
- onZap = { t ->
- addresses[t.artistId]?.let { addr ->
- zapAddress = addr
- } ?: run {
- if (t.isZapPayable) detailTrackZap = t
- }
- },
- )
- detailTrackZap?.let { zt ->
- WavlakeZapSheet(
- track = zt,
- onDismiss = { detailTrackZap = null },
- )
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun WavlakeSettingsDialog(
- current: WavlakeStationSettings,
- onDismiss: () -> Unit,
- onSave: (WavlakeStationSettings) -> Unit,
-) {
- var enabled by remember { mutableStateOf(current.enabled) }
- var probability by remember { mutableStateOf(current.featureProbability.toFloat()) }
- var mode by remember { mutableStateOf(current.matchingMode) }
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text("Wavlake featuring") },
- text = {
- Column {
- Text(
- "Genre-matched Wavlake picks appear on the Player for compatible stations. Tapping plays — stations are never interrupted.",
- style = MaterialTheme.typography.bodySmall,
- color = LocalTokens.current.textSecondary,
- )
- Spacer(modifier = Modifier.height(12.dp))
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- "Enabled",
- modifier = Modifier.weight(1f),
- color = LocalTokens.current.text,
- )
- Switch(checked = enabled, onCheckedChange = { enabled = it })
- }
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- "Refresh chance: ${(probability * 100).toInt()}%",
- color = LocalTokens.current.text,
- )
- Slider(
- value = probability,
- onValueChange = { probability = it },
- valueRange = 0f..1f,
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text("Matching", color = LocalTokens.current.text)
- Spacer(modifier = Modifier.height(4.dp))
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- MatchingMode.entries.forEach { m ->
- TextButton(onClick = { mode = m }) {
- Text(
- m.name.lowercase().replace('_', '-'),
- color = if (mode == m) LocalTokens.current.accentText
- else LocalTokens.current.textSecondary,
- fontWeight = if (mode == m) FontWeight.Bold else FontWeight.Normal,
- )
- }
- }
- }
- }
- },
- confirmButton = {
- TextButton(
- onClick = {
- onSave(
- current.copy(
- enabled = enabled,
- featureProbability = probability.toDouble(),
- matchingMode = mode,
- ),
- )
- },
- ) { Text("Save") }
- },
- dismissButton = {
- TextButton(onClick = onDismiss) { Text("Cancel") }
- },
- )
-}
-
-/**
- * Opt-in genre-matched picks for the current station. Shown on the Player;
- * tapping a pick plays it (explicit choice), nothing auto-inserts.
- */
-@Composable
-fun WavlakeFeaturedRow(
- stationId: String,
- stationGenre: String,
- viewModel: WavlakeViewModel = hiltViewModel(),
-) {
- val tokens = LocalTokens.current
- var picks by remember(stationId) { mutableStateOf>(emptyList()) }
- var loading by remember(stationId) { mutableStateOf(true) }
-
- LaunchedEffect(stationId) {
- loading = true
- picks = viewModel.featuredFor(stationId, stationGenre)
- loading = false
- }
-
- if (!loading && picks.isEmpty()) return
-
- Column(modifier = Modifier.fillMaxWidth()) {
- Text(
- text = "FEATURING WAVLAKE",
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.Bold,
- color = tokens.accentText,
- modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
- )
- if (loading) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.Center,
- ) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- strokeWidth = 2.dp,
- color = tokens.accentText,
- )
- }
- } else {
- androidx.compose.foundation.lazy.LazyRow(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 24.dp),
- ) {
- items(picks, key = { it.id }) { track ->
- FeaturedChip(
- track = track,
- onPlay = { viewModel.playTrack(track) },
- onSave = { viewModel.saveTrack(track) },
- )
- }
- }
- }
- }
-}
-
-/**
- * Official Wavlake track zap (no artist address needed): amount → invoice
- * via wavlake.com, then pay in-app (wallet) or copy the invoice out to any
- * wallet. Success is shown only after confirmation.
- */
-@Composable
-fun WavlakeZapSheet(
- track: WavlakeTrack,
- onDismiss: () -> Unit,
- viewModel: WavlakeZapViewModel = hiltViewModel(),
-) {
- val tokens = LocalTokens.current
- val state by viewModel.state.collectAsState()
- var amount by remember(track.id) { mutableStateOf(100L) }
- var customAmount by remember(track.id) { mutableStateOf("") }
- var comment by remember(track.id) { mutableStateOf("") }
- val finalSats = customAmount.toLongOrNull()?.takeIf { it > 0 } ?: amount
-
- LaunchedEffect(track.id) { viewModel.reset() }
-
- AlertDialog(
- onDismissRequest = onDismiss,
- containerColor = tokens.panel,
- titleContentColor = tokens.text,
- shape = RoundedCornerShape(20.dp),
- title = {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText)
- Spacer(Modifier.size(8.dp))
- Text(
- "Zap ${track.title}",
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- },
- text = {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- Text(
- "by ${track.artistName} · via Wavlake",
- style = MaterialTheme.typography.bodySmall,
- color = tokens.textMuted,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- when (val s = state) {
- TrackZapState.Editing -> {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- listOf(21L, 100L, 500L, 1000L).forEach { preset ->
- FilterChip(
- selected = customAmount.isBlank() && amount == preset,
- onClick = { amount = preset; customAmount = "" },
- label = { Text("$preset") },
- colors = FilterChipDefaults.filterChipColors(
- selectedContainerColor = tokens.accentSoft,
- selectedLabelColor = tokens.accentText,
- labelColor = tokens.textSecondary,
- ),
- )
- }
- }
- OutlinedTextField(
- value = customAmount,
- onValueChange = { customAmount = it.filter(Char::isDigit) },
- placeholder = { Text("Custom amount in sats") },
- singleLine = true,
- suffix = { Text("sats", color = tokens.textMuted) },
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = tokens.accentText,
- unfocusedBorderColor = tokens.borderInteractive,
- focusedTextColor = tokens.text,
- unfocusedTextColor = tokens.text,
- ),
- modifier = Modifier.fillMaxWidth(),
- )
- OutlinedTextField(
- value = comment,
- onValueChange = { comment = it },
- placeholder = { Text("Message (optional)") },
- singleLine = true,
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = tokens.accentText,
- unfocusedBorderColor = tokens.borderInteractive,
- focusedTextColor = tokens.text,
- unfocusedTextColor = tokens.text,
- ),
- modifier = Modifier.fillMaxWidth(),
- )
- }
- TrackZapState.Sending -> {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.Center,
- ) {
- CircularProgressIndicator(
- modifier = Modifier.size(24.dp),
- strokeWidth = 2.dp,
- color = tokens.accentText,
- )
- }
- }
- is TrackZapState.InvoiceReady -> {
- Text(
- "Invoice for ${s.sats} sats — copy it into any wallet app, no connected wallet needed.",
- style = MaterialTheme.typography.bodyMedium,
- color = tokens.text,
- )
- Text(
- s.bolt11.take(42) + "…",
- style = MaterialTheme.typography.bodySmall,
- color = tokens.textMuted,
- )
- }
- TrackZapState.Sent -> {
- Text(
- "⚡ Zap confirmed. The artist gets paid via Wavlake.",
- style = MaterialTheme.typography.bodyMedium,
- color = tokens.accentText,
- )
- }
- is TrackZapState.Failed -> {
- Text(
- s.reason,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.error,
- )
- }
- }
- }
- },
- confirmButton = {
- when (val s = state) {
- TrackZapState.Editing -> {
- TextButton(
- onClick = { viewModel.send(track, finalSats, comment) },
- enabled = finalSats > 0,
- ) { Text("Send $finalSats sats") }
- }
- is TrackZapState.InvoiceReady -> {
- val clipboard = androidx.compose.ui.platform.LocalClipboardManager.current
- TextButton(onClick = {
- clipboard.setText(androidx.compose.ui.text.AnnotatedString(s.bolt11))
- }) { Text("Copy invoice") }
- }
- is TrackZapState.Failed -> {
- TextButton(onClick = { viewModel.reset() }) { Text("Back") }
- }
- else -> {}
- }
- },
- dismissButton = {
- when (state) {
- TrackZapState.Editing -> {
- TextButton(
- onClick = { viewModel.fetchInvoice(track, finalSats, comment) },
- enabled = finalSats > 0,
- ) { Text("Invoice only") }
- }
- else -> {
- TextButton(onClick = onDismiss) {
- Text(if (state == TrackZapState.Sent) "Done" else "Close")
- }
- }
- }
- },
- )
-}
-
-@Composable
-private fun FeaturedChip( track: WavlakeTrack,
- onPlay: () -> Unit,
- onSave: () -> Unit,
-) {
- val tokens = LocalTokens.current
- Card(
- onClick = onPlay,
- colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong),
- shape = RoundedCornerShape(SatsAmpGlass.rInput),
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- EqualizerBars(active = false, color = tokens.accentText)
- Spacer(modifier = Modifier.width(8.dp))
- Column {
- Text(
- text = track.title,
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = FontWeight.SemiBold,
- color = tokens.text,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Text(
- text = track.artistName,
- style = MaterialTheme.typography.bodySmall,
- color = tokens.textSecondary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- Spacer(modifier = Modifier.width(8.dp))
- IconButton(onClick = onSave, modifier = Modifier.size(40.dp)) {
- Icon(
- Icons.Default.Add,
- contentDescription = "Save ${track.title} to Library",
- tint = tokens.textSecondary,
- modifier = Modifier.size(18.dp),
- )
- }
- }
- }
-}
diff --git a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt
deleted file mode 100644
index c76862e..0000000
--- a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt
+++ /dev/null
@@ -1,304 +0,0 @@
-package com.rada.ui.discover
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.rada.data.repository.PlaylistRepository
-import com.rada.data.wavlake.WavlakeRepository
-import com.rada.data.wavlake.WavlakeResult
-import com.rada.data.wavlake.WavlakeSearchHit
-import com.rada.data.wavlake.WavlakeStationSettings
-import com.rada.domain.model.WavlakeArtist
-import com.rada.domain.model.WavlakeTrack
-import com.rada.payments.hasValidLightningAddress
-import com.rada.player.PlaybackManager
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.debounce
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-/** Curated genre shortcuts — exact Wavlake catalog names only. */
-val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock")
-
-sealed interface WavlakeSearchState {
- data object Idle : WavlakeSearchState
- data object Loading : WavlakeSearchState
- data class Results(val hits: List) : WavlakeSearchState
- data class Error(val message: String) : WavlakeSearchState
-}
-
-@OptIn(FlowPreview::class)
-@HiltViewModel
-class WavlakeViewModel @Inject constructor(
- private val repository: WavlakeRepository,
- private val playbackManager: PlaybackManager,
- private val playlists: PlaylistRepository,
-) : ViewModel() {
-
- private val _searchQuery = MutableStateFlow("")
- val searchQuery: StateFlow = _searchQuery.asStateFlow()
-
- private val _searchState = MutableStateFlow(WavlakeSearchState.Idle)
- val searchState: StateFlow = _searchState.asStateFlow()
-
- private val _rankings = MutableStateFlow>(emptyList())
- val rankings: StateFlow> = _rankings.asStateFlow()
-
- private val _rankingsGenre = MutableStateFlow(null)
- val rankingsGenre: StateFlow = _rankingsGenre.asStateFlow()
-
- private val _rankingsLoading = MutableStateFlow(false)
- val rankingsLoading: StateFlow = _rankingsLoading.asStateFlow()
-
- private val _rankingsError = MutableStateFlow(null)
- val rankingsError: StateFlow = _rankingsError.asStateFlow()
-
- /** Resolved Lightning Addresses by artist id — populated best-effort. */
- private val _addresses = MutableStateFlow