Checkpoint all work; ignore build artifacts and gradle caches

This commit is contained in:
avi 2026-09-23 18:58:21 -05:00
commit 36f61ddb40
2443 changed files with 19745 additions and 51933 deletions

View file

@ -7,6 +7,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- NIP-55 external signer discovery (Amber, etc.) -->
<queries>

View file

@ -35,6 +35,8 @@ class SongImporter @Inject constructor(
private val playlistRepository: PlaylistRepository,
) {
private val folderPrefs = context.getSharedPreferences("library_import_folders", Context.MODE_PRIVATE)
/** Entry point. Each uri may be a single document or a picked tree/folder. */
suspend fun import(uris: List<Uri>): ImportResult {
var imported = 0
@ -43,6 +45,9 @@ class SongImporter @Inject constructor(
val errors = mutableListOf<String>()
for (uri in uris) {
if (uri.path?.startsWith("/tree/") == true) {
persistFolderPermission(uri)
}
// Folder picks arrive as tree URIs (…/tree/primary:…); file picks
// arrive as document URIs. fromSingleUri() misreads tree URIs as
// files, so detect trees by their path first.
@ -61,6 +66,7 @@ class SongImporter @Inject constructor(
doc == null -> Log.w(TAG, "Cannot open picked tree: $uri")
doc.isDirectory -> {
val folderName = doc.name?.takeIf { it.isNotBlank() } ?: "Imported folder"
folderPrefs.edit().putString(folderName, uri.toString()).apply()
val audio = mutableListOf<Uri>()
collectAudioFromTree(doc, audio)
if (audio.isEmpty()) {
@ -80,6 +86,26 @@ class SongImporter @Inject constructor(
return ImportResult(imported, skipped, folders, errors)
}
/** Re-scans every folder previously imported through the system folder picker. */
suspend fun refreshImportedFolders(): ImportResult {
val uris = folderPrefs.all.values.mapNotNull { value ->
(value as? String)?.let(Uri::parse)
}
if (uris.isEmpty()) {
return ImportResult(0, 0, errors = listOf("No imported folders to refresh"))
}
return import(uris)
}
private fun persistFolderPermission(uri: Uri) {
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION,
)
}
}
private fun collectAudioFromTree(dir: DocumentFile, out: MutableList<Uri>) {
try {
for (child in dir.listFiles()) {

View file

@ -278,7 +278,13 @@ class ZapService @Inject constructor(
)
WavlakeZapOutcome.Sent
}
is PaymentResult.Failed -> WavlakeZapOutcome.Failed(pay.userFacing)
is PaymentResult.Failed -> {
// The invoice is still valid when an in-app wallet cannot pay
// it (for example, an external signer cannot decrypt NWC).
// Preserve it so the user can finish the zap in any wallet.
Log.w("Zap", "In-app payment failed: ${pay.reason}")
WavlakeZapOutcome.Invoice(invoice.bolt11, sats, invoice.requestId)
}
else -> WavlakeZapOutcome.Failed("Unexpected wallet response.")
}
}

View file

@ -25,6 +25,7 @@ import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@AndroidEntryPoint
@ -48,16 +49,17 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
playbackManager.connect()
askNotificationPermissionOnce()
warmRepositories()
setContent {
SatsAmpTheme {
GlassBackground {
Surface(
modifier = Modifier.fillMaxSize(),
color = Color.Transparent,
) {
NavGraph(navController = rememberNavController())
lifecycleScope.launch {
warmRepositories()
setContent {
SatsAmpTheme {
GlassBackground {
Surface(
modifier = Modifier.fillMaxSize(),
color = Color.Transparent,
) {
NavGraph(navController = rememberNavController())
}
}
}
}
@ -76,16 +78,14 @@ class MainActivity : ComponentActivity() {
* stall tab switches. Hilt caches singletons thread-safely, so later
* main-thread access is free.
*/
private fun warmRepositories() {
lifecycleScope.launch(Dispatchers.IO) {
runCatching {
val entry = EntryPointAccessors.fromApplication(
applicationContext, WarmEntryPoint::class.java,
)
entry.stationRepository().getAll()
entry.streamRepository().getAll()
entry.wavlakeRepository().isStationEnabled("")
}
private suspend fun warmRepositories() = withContext(Dispatchers.IO) {
runCatching {
val entry = EntryPointAccessors.fromApplication(
applicationContext, WarmEntryPoint::class.java,
)
entry.stationRepository().getAll()
entry.streamRepository().getAll()
entry.wavlakeRepository().isStationEnabled("")
}
}

View file

@ -0,0 +1,162 @@
package com.rada.ui.components
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.compose.ui.window.Dialog
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
@androidx.annotation.OptIn(markerClass = [ExperimentalGetImage::class])
@Composable
fun QrScannerDialog(
onResult: (String) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED,
)
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> hasCameraPermission = granted }
LaunchedEffect(Unit) {
if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA)
}
if (!hasCameraPermission) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Camera access needed") },
text = { Text("Allow camera access to scan a Nostr Wallet Connect QR code.") },
confirmButton = {
TextButton(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) {
Text("Allow")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
return
}
val lifecycleOwner = LocalLifecycleOwner.current
Dialog(onDismissRequest = onDismiss) {
Surface {
Column {
androidx.compose.foundation.layout.Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.End,
) {
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close scanner")
}
}
val previewView = remember { PreviewView(context) }
AndroidView(
factory = { previewView },
modifier = Modifier
.fillMaxWidth()
.size(300.dp)
.padding(16.dp),
)
Text(
"Point the camera at the wallet's Nostr Wallet Connect QR code.",
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
)
DisposableEffect(previewView, lifecycleOwner) {
val executor = Executors.newSingleThreadExecutor()
val scanner = BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build(),
)
val delivered = AtomicBoolean(false)
val providerFuture = ProcessCameraProvider.getInstance(context)
providerFuture.addListener({
val provider = providerFuture.get()
val preview = Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val analysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
analysis.setAnalyzer(executor) { imageProxy ->
val image = imageProxy.image
if (image == null || delivered.get()) {
imageProxy.close()
return@setAnalyzer
}
scanner.process(
InputImage.fromMediaImage(
image,
imageProxy.imageInfo.rotationDegrees,
),
).addOnSuccessListener { codes ->
val value = codes.firstNotNullOfOrNull { it.rawValue }
if (value != null && delivered.compareAndSet(false, true)) {
onResult(value)
onDismiss()
}
}.addOnCompleteListener { imageProxy.close() }
}
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
analysis,
)
}, ContextCompat.getMainExecutor(context))
onDispose {
runCatching { providerFuture.get().unbindAll() }
scanner.close()
executor.shutdown()
}
}
}
}
}
}

View file

@ -19,6 +19,7 @@ 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.material.icons.filled.QrCodeScanner
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -48,14 +49,17 @@ 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 androidx.camera.core.ExperimentalGetImage
import com.rada.ui.theme.LocalTokens
import com.rada.ui.theme.SatsAmpGlass
import com.rada.ui.components.QrScannerDialog
/**
* Decentralized settings sheet — progressive disclosure per the design:
* listeners see "identity + wallet", advanced rows (relays, profile) sit
* behind a toggle. All state via [DecentralizedViewModel].
*/
@androidx.annotation.OptIn(markerClass = [ExperimentalGetImage::class])
@Composable
fun DecentralizedSheet(
onDismiss: () -> Unit,
@ -92,6 +96,7 @@ fun DecentralizedSheet(
var nwcInput by remember { mutableStateOf("") }
var nameInput by remember { mutableStateOf("") }
var aboutInput by remember { mutableStateOf("") }
var showQrScanner by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
@ -239,14 +244,23 @@ fun DecentralizedSheet(
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(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = nwcInput,
onValueChange = { nwcInput = it },
placeholder = { Text("nostr+walletconnect://…") },
singleLine = true,
colors = fieldColors(tokens),
modifier = Modifier.weight(1f),
)
IconButton(onClick = { showQrScanner = true }) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = "Scan wallet QR code",
tint = tokens.accentText,
)
}
}
Button(
onClick = { viewModel.connectWallet(nwcInput) },
enabled = nwcInput.isNotBlank() && busy.not(),
@ -356,6 +370,19 @@ fun DecentralizedSheet(
TextButton(onClick = onDismiss) { Text("Close", color = tokens.accentText) }
},
)
if (showQrScanner) {
QrScannerDialog(
onResult = { scanned ->
if (scanned.startsWith("nostr+walletconnect://")) {
nwcInput = scanned
} else {
viewModel.showMessage("That QR code is not a Nostr Wallet Connect pairing code.")
}
},
onDismiss = { showQrScanner = false },
)
}
}
@Composable

View file

@ -137,4 +137,6 @@ class DecentralizedViewModel @Inject constructor(
}
fun consumeMessage() { message.value = null }
fun showMessage(text: String) { message.value = text }
}

View file

@ -2,6 +2,7 @@ package com.rada.ui.discover
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import android.util.Log
import com.rada.domain.model.WavlakeTrack
import com.rada.payments.ZapService
import dagger.hilt.android.lifecycle.HiltViewModel
@ -49,8 +50,9 @@ class WavlakeZapViewModel @Inject constructor(
is ZapService.WavlakeZapOutcome.Failed ->
TrackZapState.Failed(r.reason)
}
} catch (e: Exception) {
TrackZapState.Failed(e.message ?: "Invoice failed")
} catch (e: Throwable) {
Log.e(TAG, "Wavlake invoice failed", e)
TrackZapState.Failed(e.message ?: "Crypto or network setup failed")
}
}
}
@ -68,9 +70,14 @@ class WavlakeZapViewModel @Inject constructor(
is ZapService.WavlakeZapOutcome.Failed ->
TrackZapState.Failed(r.reason)
}
} catch (e: Exception) {
TrackZapState.Failed(e.message ?: "Zap failed")
} catch (e: Throwable) {
Log.e(TAG, "Wavlake zap failed", e)
TrackZapState.Failed(e.message ?: "Crypto, wallet, or network setup failed")
}
}
}
private companion object {
const val TAG = "WavlakeZap"
}
}

View file

@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.PlaylistAdd
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.Settings
@ -76,6 +77,7 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -195,6 +197,20 @@ fun LibraryScreen(
Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play")
}
}
IconButton(
onClick = { viewModel.refreshImportedFolders() },
enabled = !uiState.isImporting,
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh imported folders",
tint = if (uiState.isImporting) {
LocalTokens.current.textSecondary.copy(alpha = 0.4f)
} else {
LocalTokens.current.textSecondary
},
)
}
// Reliable delete path for the selected folder (besides long-press).
if (uiState.selectedPlaylistId != null) {
allPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }?.let { selected ->
@ -451,6 +467,8 @@ fun LibraryScreen(
},
style = MaterialTheme.typography.bodyLarge,
color = LocalTokens.current.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp),
)
}
}

View file

@ -67,6 +67,23 @@ class LibraryViewModel @Inject constructor(
}
}
fun refreshImportedFolders() {
if (_uiState.value.isImporting) return
_uiState.update { it.copy(isImporting = true) }
viewModelScope.launch {
val result = songImporter.refreshImportedFolders()
_uiState.update { it.copy(isImporting = false) }
_importMessage.value = buildString {
append("Refreshed ${result.imported} new song")
if (result.imported != 1) append("s")
if (result.skipped > 0) append(" · ${result.skipped} already in library")
if (result.errors.isNotEmpty()) {
append(" (${result.errors.first()})")
}
}
}
}
fun clearImportMessage() {
_importMessage.value = null
}

View file

@ -51,16 +51,12 @@ fun TabsScreen(
val scope = rememberCoroutineScope()
// Deep-link target for Library (playlist picker → open that playlist).
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
// Tab taps jump: swiping is the animated path, but animating a tap from
// Stations → Playlists scrolls through (and composes) every page in
// between — that mid-flight compose/dispose churn is the jumpiness.
// Adjacent taps still glide; distant ones land instantly like Spotify.
// Tab taps land immediately. Swiping remains animated, but animating a
// tap composes and disposes heavy pages while the gesture is in flight.
fun goToPage(page: Int) {
val from = pagerState.currentPage
if (from == page) return
if (pagerState.currentPage == page) return
scope.launch {
if (kotlin.math.abs(from - page) == 1) pagerState.animateScrollToPage(page)
else pagerState.scrollToPage(page)
pagerState.scrollToPage(page)
}
}

View file

@ -4,7 +4,6 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.togetherWith
import com.rada.ui.components.EqualizerBars
import com.rada.ui.theme.metadataEnter
import com.rada.ui.theme.metadataExit
import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.barEnter
@ -186,20 +185,14 @@ fun NowPlayingBar(
),
) {
Column {
AnimatedContent(
targetState = title,
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
label = "npTitle",
) { t ->
Text(
text = t,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = LocalTokens.current.text,
)
}
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = LocalTokens.current.text,
)
if (isError) {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -222,19 +215,13 @@ fun NowPlayingBar(
)
}
} else if (subtitle.isNotEmpty()) {
AnimatedContent(
targetState = subtitle,
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
label = "npSubtitle",
) { st ->
Text(
text = st,
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View file

@ -10,7 +10,6 @@ import androidx.compose.animation.togetherWith
import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.MotionArrival
import com.rada.ui.theme.MotionExit
import com.rada.ui.theme.metadataEnter
import com.rada.ui.theme.metadataExit
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
@ -574,47 +573,36 @@ private fun NowPlayingView(
Spacer(modifier = Modifier.height(32.dp))
// Artist/Station name
AnimatedContent(
targetState = uiState.artist,
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
label = "artist",
) { artist ->
// Replace metadata immediately. Some stations emit updates in quick
// succession; animated transitions can queue and show stale titles.
Text(
text = uiState.artist.ifEmpty { "No station" },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
// Song title
if (uiState.title.isNotEmpty()) {
Text(
text = artist.ifEmpty { "No station" },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
text = uiState.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 8.dp),
)
} else if (uiState.isPlaying && uiState.currentStation != null) {
Text(
text = if (uiState.hasMetadata) "" else "No track info from this station",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
}
// Song title
AnimatedContent(
targetState = uiState.title,
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
label = "title",
) { title ->
if (title.isNotEmpty()) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 8.dp),
)
} else if (uiState.isPlaying && uiState.currentStation != null) {
Text(
text = if (uiState.hasMetadata) "" else "No track info from this station",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
}
}
Spacer(modifier = Modifier.height(32.dp))