Merge pull request #3635 from vitorpamplona/claude/amethyst-blossom-client-p35sql

Add Blossom blob manager UI and BUD-07 payment handling
This commit is contained in:
Vitor Pamplona
2026-07-18 10:21:08 -04:00
committed by GitHub
38 changed files with 3262 additions and 466 deletions
+10
View File
@@ -54,6 +54,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Phone calls -->
@@ -373,6 +374,15 @@
android:stopWithTask="false"
android:exported="false" />
<!-- Keeps the "sync all blobs to all servers" (BUD-04) sweep alive while backgrounded.
dataSync is the correct type for an upload/download/sync; it must be started from the
foreground, which "Sync all" (user-initiated in the manager) satisfies. -->
<service
android:name=".service.uploads.blossom.BlossomSyncForegroundService"
android:foregroundServiceType="dataSync"
android:stopWithTask="false"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -107,6 +107,8 @@ import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncForegroundService
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
@@ -795,6 +797,14 @@ class AppModules(
}
}
/** App-level BUD-04 mirror sweep, so "sync all" keeps running as the user navigates. */
val blossomMirrorQueue by lazy {
BlossomMirrorQueue(
scope = applicationIOScope,
onActive = { BlossomSyncForegroundService.start(appContext) },
)
}
val powJobRestorer by lazy {
PowJobRestorer(powPublishQueue, powJobStore, scheduledPostStore)
}
@@ -109,6 +109,8 @@ private object PrefKeys {
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache"
const val LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY = "localBlossomCacheProfilePicturesOnly"
const val MIRROR_UPLOADS_TO_ALL_SERVERS = "mirrorUploadsToAllServers"
const val OPTIMIZE_MEDIA_ON_UPLOAD = "optimizeMediaOnUpload"
const val HIDE_COMMUNITY_RULES_VIOLATIONS = "hideCommunityRulesViolations"
const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled"
const val NIP46_BUNKER_SECRET = "nip46BunkerSecret"
@@ -473,6 +475,8 @@ object LocalPreferences {
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value)
putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value)
putBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, settings.mirrorUploadsToAllServers.value)
putBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, settings.optimizeMediaOnUpload.value)
putBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, settings.hideCommunityRulesViolations.value)
putBoolean(PrefKeys.NIP46_SIGNER_ENABLED, settings.nip46SignerEnabled.value)
putString(PrefKeys.NIP46_BUNKER_SECRET, settings.nip46BunkerSecret.value)
@@ -688,6 +692,8 @@ object LocalPreferences {
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true)
val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false)
val mirrorUploadsToAllServers = getBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, true)
val optimizeMediaOnUpload = getBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, false)
val hideCommunityRulesViolations = getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false)
val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false)
val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: ""
@@ -872,6 +878,8 @@ object LocalPreferences {
stripLocationOnUpload = stripLocationOnUpload,
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly),
mirrorUploadsToAllServers = MutableStateFlow(mirrorUploadsToAllServers),
optimizeMediaOnUpload = MutableStateFlow(optimizeMediaOnUpload),
hideCommunityRulesViolations = MutableStateFlow(hideCommunityRulesViolations),
nip46SignerEnabled = MutableStateFlow(nip46SignerEnabled),
nip46BunkerSecret = MutableStateFlow(nip46BunkerSecret),
@@ -1594,12 +1594,26 @@ class Account(
hash: HexKey,
size: Long,
alt: String,
) = blossomServers.createBlossomUploadAuth(hash, size, alt)
servers: List<String> = emptyList(),
) = blossomServers.createBlossomUploadAuth(hash, size, alt, servers)
suspend fun createBlossomMediaAuth(
hash: HexKey,
size: Long,
alt: String,
servers: List<String> = emptyList(),
) = blossomServers.createBlossomMediaAuth(hash, size, alt, servers)
suspend fun createBlossomDeleteAuth(
hash: HexKey,
alt: String,
) = blossomServers.createBlossomDeleteAuth(hash, alt)
servers: List<String> = emptyList(),
) = blossomServers.createBlossomDeleteAuth(hash, alt, servers)
suspend fun createBlossomListAuth(
alt: String,
servers: List<String> = emptyList(),
) = blossomServers.createBlossomListAuth(alt, servers)
suspend fun boost(note: Note) {
val powDifficulty = powDifficultyFor(RepostEvent.KIND)
@@ -186,6 +186,16 @@ class AccountSettings(
var stripLocationOnUpload: Boolean = true,
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
val localBlossomCacheProfilePicturesOnly: MutableStateFlow<Boolean> = MutableStateFlow(false),
/**
* BUD-04: after uploading a blob to the primary Blossom server, replicate it to
* the user's other configured servers (kind 10063) for redundancy.
*/
val mirrorUploadsToAllServers: MutableStateFlow<Boolean> = MutableStateFlow(true),
/**
* BUD-05: upload media through the server's `/media` endpoint so the server may
* strip metadata and optimize it, instead of the bit-exact `/upload`.
*/
val optimizeMediaOnUpload: MutableStateFlow<Boolean> = MutableStateFlow(false),
/**
* NIP-46: when true, this account acts as a remote signer (a "bunker") for
* other apps, listening on the user's inbox relays for kind:24133 requests.
@@ -665,6 +675,20 @@ class AccountSettings(
}
}
fun changeMirrorUploadsToAllServers(enabled: Boolean) {
if (mirrorUploadsToAllServers.value != enabled) {
mirrorUploadsToAllServers.tryEmit(enabled)
saveAccountSettings()
}
}
fun changeOptimizeMediaOnUpload(enabled: Boolean) {
if (optimizeMediaOnUpload.value != enabled) {
optimizeMediaOnUpload.tryEmit(enabled)
saveAccountSettings()
}
}
fun updateAddClientTag(add: Boolean): Boolean =
if (syncedSettings.security.updateAddClientTag(add)) {
saveAccountSettings()
@@ -120,12 +120,26 @@ class BlossomServerListState(
hash: HexKey,
size: Long,
alt: String,
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer)
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, servers)
suspend fun createBlossomMediaAuth(
hash: HexKey,
size: Long,
alt: String,
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createMediaAuth(hash, size, alt, signer, servers)
suspend fun createBlossomDeleteAuth(
hash: HexKey,
alt: String,
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer)
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, servers)
suspend fun createBlossomListAuth(
alt: String,
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createListAuth(signer, alt, servers)
}
/**
@@ -0,0 +1,298 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.foreground
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Shared scaffolding for a foreground service that shields a background job from the
* cached-apps freezer while it runs, rendering a live [NotificationCompat.ProgressStyle]
* progress card driven by a [StateFlow].
*
* This is deliberately NOT a single "do everything" service: Android 14+ binds
* `foregroundServiceType` to the service's actual behavior (and each type carries its
* own permission, budget and start rules), so each workload keeps its own concrete
* subclass with the correct [fgsType]. What's shared here is only the boilerplate —
* channel setup, start/stop-when-idle, the notification skeleton, tap + cancel intents,
* and `onTimeout` — parameterized by a handful of hooks.
*
* Subclasses supply the [fgsType], the [state] flow to watch, [isActive] to decide when
* to stop, [render] to build the card, and [cancelAll] for the cancel action. See
* `PowMiningForegroundService` (shortService) and `BlossomSyncForegroundService`
* (dataSync) for the two consumers.
*/
abstract class FlowProgressForegroundService<T> : Service() {
protected val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
private var watchJob: Job? = null
/** The Android 14+ `ServiceInfo.FOREGROUND_SERVICE_TYPE_*` this service runs as. */
protected abstract val fgsType: Int
protected abstract val channelId: String
protected abstract val channelNameRes: Int
protected abstract val channelDescRes: Int
protected abstract val notificationId: Int
/** The intent action that routes back here to cancel everything. */
protected abstract val cancelAction: String
protected abstract val cancelLabelRes: Int
protected open val smallIcon: Int = R.drawable.amethyst
/** When non-null, re-render the card on this cadence (for clock-driven text like "time left"). */
protected open val refreshMs: Long? = null
protected abstract fun state(): StateFlow<T>
/** Keep the service (and notification) alive while this is true; stop once it goes false. */
protected abstract fun isActive(value: T): Boolean
protected abstract fun render(value: T): Content
/** Invoked by the cancel action. */
protected abstract fun cancelAll()
/** Called for every emission before [render]; use to update derived subclass state. */
protected open fun onEmission(value: T) {}
/** Only consulted for the [refreshMs] clock loop; skip re-renders when nothing is moving. */
protected open fun needsClockRefresh(value: T): Boolean = true
/** One-time setup once the watch loop starts (e.g. a benchmark). */
protected open fun onStarted() {}
/** How to draw the progress bar of the card. */
sealed interface Bar {
data object Indeterminate : Bar
/** A single bar filled to [fraction] in `0f..1f`. */
data class Determinate(
val fraction: Double,
) : Bar
/** [total] equal segments, [done] of them filled — good for "N of M". */
data class Segmented(
val total: Int,
val done: Int,
) : Bar
}
data class Content(
val title: String,
val text: String?,
val bar: Bar,
)
private val tapIntent: PendingIntent by lazy {
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
private val cancelIntent: PendingIntent by lazy {
PendingIntent.getService(
this,
1,
Intent(this, this.javaClass).setAction(cancelAction),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
// Android's contract: every onStartCommand after startForegroundService must call
// startForeground promptly, even on the stop path.
runCatching { startForegroundCompat(state().value) }
.onFailure {
Log.w(logTag(), "startForeground failed; work continues without the service", it)
stopSelf()
return START_NOT_STICKY
}
if (intent?.action == cancelAction) {
cancelAll()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
watch()
return START_NOT_STICKY
}
/** Foreground-service budget exhausted (shortService ~3 min; dataSync on newer OS). Exit cleanly. */
override fun onTimeout(startId: Int) {
Log.d(logTag()) { "foreground-service budget exhausted; stopping" }
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun watch() {
if (watchJob != null) return
onStarted()
watchJob =
scope.launch {
state().collect { value ->
onEmission(value)
if (!isActive(value)) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
updateNotification(value)
}
}
}
refreshMs?.let { ms ->
scope.launch {
while (true) {
val v = state().value
if (isActive(v) && needsClockRefresh(v)) updateNotification(v)
delay(ms)
}
}
}
}
private fun startForegroundCompat(value: T) {
ensureChannel()
val notification = buildNotification(value)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(notificationId, notification, fgsType)
} else {
startForeground(notificationId, notification)
}
}
private fun updateNotification(value: T) {
val manager = NotificationManagerCompat.from(this)
if (!manager.areNotificationsEnabled()) return
try {
manager.notify(notificationId, buildNotification(value))
} catch (_: SecurityException) {
// POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
}
}
private fun buildNotification(value: T): Notification {
val content = render(value)
val style =
when (val bar = content.bar) {
is Bar.Indeterminate -> NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
is Bar.Determinate ->
if (bar.fraction.isFinite() && bar.fraction in 0.0..1.0) {
NotificationCompat
.ProgressStyle()
.setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100)))
.setProgress((bar.fraction * 100).toInt())
} else {
NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
}
is Bar.Segmented ->
NotificationCompat
.ProgressStyle()
.setProgressSegments(List(bar.total.coerceAtLeast(1)) { NotificationCompat.ProgressStyle.Segment(1) })
.setProgress(bar.done)
}
return NotificationCompat
.Builder(this, channelId)
.setSmallIcon(smallIcon)
.setContentTitle(content.title)
.setContentText(content.text)
.setStyle(style)
.setContentIntent(tapIntent)
.addAction(0, stringRes(this, cancelLabelRes), cancelIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
private fun ensureChannel() {
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(channelId) != null) return
manager.createNotificationChannel(
NotificationChannel(channelId, stringRes(this, channelNameRes), NotificationManager.IMPORTANCE_LOW).apply {
description = stringRes(this@FlowProgressForegroundService, channelDescRes)
setShowBadge(false)
},
)
}
protected open fun logTag(): String = this.javaClass.simpleName
companion object {
/**
* Best-effort start of a [FlowProgressForegroundService] subclass. A start from the
* background (e.g. a restore) may be denied — the work then proceeds unprotected and
* the service starts on the next foreground trigger.
*/
fun start(
context: Context,
clazz: Class<out FlowProgressForegroundService<*>>,
tag: String,
) {
try {
context.startForegroundService(Intent(context, clazz))
} catch (e: Exception) {
Log.w(tag, "Could not start foreground service (backgrounded?); work continues unprotected", e)
}
}
}
}
@@ -20,34 +20,17 @@
*/
package com.vitorpamplona.amethyst.service.pow
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
@@ -56,161 +39,72 @@ import kotlinx.coroutines.launch
* finish mining even after the user backgrounds the app.
*
* Uses the Android 14+ `shortService` type — no special permission, but a
* hard ~3 minute budget. On [onTimeout] the service exits cleanly; every
* hard ~3 minute budget. On `onTimeout` the service exits cleanly; every
* persistable job is already checkpointed by [PowJobStore], so anything still
* unmined resumes on the next app launch. Started on every enqueue (the app
* is necessarily in the foreground then), stops itself when the queue drains.
*
* The notification is a live progress card ([NotificationCompat.ProgressStyle]):
* one track segment per post, filling as jobs complete, indeterminate while a
* single post mines, with a cancel-all action. On Android 16+ it renders as a
* Live Updates chip; older versions fall back to a standard progress bar.
* The notification card (a live [androidx.core.app.NotificationCompat.ProgressStyle]) and all the
* service lifecycle live in [FlowProgressForegroundService]; this subclass only maps mining state
* to that card.
*/
class PowMiningForegroundService : Service() {
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
private var watchJob: Job? = null
class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<PoWJobState>>() {
override val fgsType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
override val channelId = CHANNEL_ID
override val channelNameRes = R.string.pow_notification_channel_name
override val channelDescRes = R.string.pow_notification_channel_description
override val notificationId = NOTIFICATION_ID
override val cancelAction = ACTION_CANCEL_ALL
override val cancelLabelRes = R.string.pow_notification_cancel_all
// Session totals so the progress track can show "done / enqueued since the
// service started" — the queue itself only knows what is still pending.
// clock-driven refresh for the time-left text and bar; the shortService budget (~3 min)
// caps this at a handful of updates.
override val refreshMs: Long = PROGRESS_REFRESH_MS
// Session totals so the progress track can show "done / enqueued since the service
// started" — the queue itself only knows what is still pending.
private var sessionTotal = 0
private var lastQueueSize = 0
// Benchmarked once per service run (~250 ms, cached by the estimator);
// read from the notification builder to compute expected durations.
// Benchmarked once per service run (~250 ms, cached by the estimator).
@Volatile
private var hashRate: Double? = null
// Built once per service instance: the intents never change, and
// buildNotification runs on every queue update.
private val tapIntent: PendingIntent by lazy {
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
private val cancelIntent: PendingIntent by lazy {
PendingIntent.getService(
this,
1,
Intent(this, PowMiningForegroundService::class.java).setAction(ACTION_CANCEL_ALL),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
override fun onCreate() {
super.onCreate()
running = true
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
// Android's contract: every onStartCommand after startForegroundService
// must call startForeground promptly, even on the stop path.
runCatching { startForegroundCompat(currentJobs()) }
.onFailure {
Log.w(TAG, "startForeground failed; mining continues without the service", it)
stopSelf()
return START_NOT_STICKY
}
if (intent?.action == ACTION_CANCEL_ALL) {
Amethyst.instance.powPublishQueue.cancelAll()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
watchQueue()
return START_NOT_STICKY
}
/**
* The shortService budget (~3 min) is exhausted. Exit before the system
* ANRs us: persisted jobs are checkpointed and resume on next launch;
* in-memory jobs keep mining opportunistically until the process freezes.
*/
override fun onTimeout(startId: Int) {
Log.d(TAG) { "shortService budget exhausted; ${currentJobs().size} job(s) left to resume later" }
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
running = false
scope.cancel()
super.onDestroy()
}
private fun currentJobs(): ImmutableList<PoWJobState> = Amethyst.instance.powPublishQueue.jobs.value
override fun state() = Amethyst.instance.powPublishQueue.jobs
private fun watchQueue() {
if (watchJob != null) return
watchJob =
scope.launch {
Amethyst.instance.powPublishQueue.jobs.collect { jobs ->
if (jobs.size > lastQueueSize) sessionTotal += jobs.size - lastQueueSize
lastQueueSize = jobs.size
override fun isActive(value: ImmutableList<PoWJobState>) = value.isNotEmpty()
if (jobs.isEmpty()) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
updateNotification(jobs)
}
}
}
override fun cancelAll() = Amethyst.instance.powPublishQueue.cancelAll()
// the estimated-time-left figure and progress fraction only move with
// the clock, not with queue events: benchmark the hash rate once,
// then refresh the card periodically while something is mining.
scope.launch {
hashRate = PoWEstimator.hashesPerSecond()
while (true) {
val jobs = currentJobs()
if (jobs.any { it.isMining }) updateNotification(jobs)
delay(PROGRESS_REFRESH_MS)
}
}
override fun needsClockRefresh(value: ImmutableList<PoWJobState>) = value.any { it.isMining }
override fun onStarted() {
scope.launch { hashRate = PoWEstimator.hashesPerSecond() }
}
private fun startForegroundCompat(jobs: ImmutableList<PoWJobState>) {
ensureChannel(this)
val notification = buildNotification(jobs)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)
} else {
startForeground(NOTIFICATION_ID, notification)
}
override fun onEmission(value: ImmutableList<PoWJobState>) {
if (value.size > lastQueueSize) sessionTotal += value.size - lastQueueSize
lastQueueSize = value.size
}
private fun updateNotification(jobs: ImmutableList<PoWJobState>) {
val manager = NotificationManagerCompat.from(this)
if (!manager.areNotificationsEnabled()) return
try {
manager.notify(NOTIFICATION_ID, buildNotification(jobs))
} catch (_: SecurityException) {
// POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
}
}
override fun render(value: ImmutableList<PoWJobState>): Content {
val done = (sessionTotal - value.size).coerceAtLeast(0)
val total = (done + value.size).coerceAtLeast(1)
private fun buildNotification(jobs: ImmutableList<PoWJobState>): Notification {
val done = (sessionTotal - jobs.size).coerceAtLeast(0)
val total = (done + jobs.size).coerceAtLeast(1)
val current = value.firstOrNull { it.isMining } ?: value.firstOrNull()
val current = jobs.firstOrNull { it.isMining } ?: jobs.firstOrNull()
// expected duration for the job being mined right now, so the card can
// say "≈ 10 minutes left" and fill its bar toward a predictable end.
// expected duration for the job being mined right now, so the card can say
// "≈ 10 minutes left" and fill its bar toward a predictable end.
val rate = hashRate
val startedAt = current?.miningStartedAt
val expectedSec = if (current != null && rate != null) PoWEstimator.estimateSeconds(current.difficulty, rate) else null
@@ -229,44 +123,23 @@ class PowMiningForegroundService : Service() {
val fraction = if (expectedSec != null && elapsedSec != null) elapsedSec / expectedSec else null
val progressStyle: NotificationCompat.ProgressStyle =
if (total <= 1) {
// single post: fill toward the estimated duration; past the
// mean the search is memoryless, so sweep instead of lying.
if (fraction != null && fraction < 1.0) {
NotificationCompat
.ProgressStyle()
.setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100)))
.setProgress((fraction * 100).toInt())
} else {
NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
}
val title =
if (value.size > 1) {
pluralStringRes(this, R.plurals.pow_mining_progress, value.size, value.size)
} else {
NotificationCompat
.ProgressStyle()
.setProgressSegments(List(total) { NotificationCompat.ProgressStyle.Segment(1) })
.setProgress(done)
stringRes(this, R.string.pow_mining_title)
}
return NotificationCompat
.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(
if (jobs.size > 1) {
pluralStringRes(this, R.plurals.pow_mining_progress, jobs.size, jobs.size)
} else {
stringRes(this, R.string.pow_mining_title)
},
).setContentText(text)
.setStyle(progressStyle)
.setContentIntent(tapIntent)
.addAction(0, stringRes(this, R.string.pow_notification_cancel_all), cancelIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
val bar =
if (total <= 1) {
// single post: fill toward the estimated duration; past the mean the search is
// memoryless, so sweep instead of lying.
if (fraction != null && fraction < 1.0) Bar.Determinate(fraction) else Bar.Indeterminate
} else {
Bar.Segmented(total, done)
}
return Content(title, text, bar)
}
companion object {
@@ -275,45 +148,19 @@ class PowMiningForegroundService : Service() {
private const val NOTIFICATION_ID = 0x504F57 // "POW"
private const val ACTION_CANCEL_ALL = "com.vitorpamplona.amethyst.pow.CANCEL_ALL"
// clock-driven refresh cadence for the time-left text and bar; the
// shortService budget (~3 min) caps this at a handful of updates.
private const val PROGRESS_REFRESH_MS = 30_000L
// Best-effort de-dup for start(): the queue calls it on EVERY enqueue,
// and each call otherwise round-trips through system_server. A stale
// false only costs one redundant startForegroundService (which Android
// routes to the existing instance's onStartCommand anyway).
// Best-effort de-dup for start(): the queue calls it on EVERY enqueue.
@Volatile
private var running = false
/**
* Best-effort start: enqueue happens while the user is interacting
* with the app, so the foreground-start allowance normally holds. A
* restore during a cold background launch may be denied — mining then
* proceeds unprotected and the service starts on the next enqueue.
* Best-effort start: enqueue happens while the user is interacting with the app,
* so the foreground-start allowance normally holds.
*/
fun start(context: Context) {
if (running) return
try {
context.startForegroundService(Intent(context, PowMiningForegroundService::class.java))
} catch (e: Exception) {
Log.w(TAG, "Could not start mining foreground service (backgrounded?); mining continues unprotected", e)
}
}
private fun ensureChannel(context: Context) {
val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
stringRes(context, R.string.pow_notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = stringRes(context, R.string.pow_notification_channel_description)
setShowBadge(false)
},
)
FlowProgressForegroundService.start(context, PowMiningForegroundService::class.java, TAG)
}
}
}
@@ -24,6 +24,8 @@ import android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -34,10 +36,13 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
@@ -200,6 +205,9 @@ class UploadOrchestrator {
context: Context,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
// BUD-05: route through /media (optimize) when the user opted in. The forced-signer
// path (e.g. NIP-46 draft signing) always uses the bit-exact /upload.
val useMedia = forcedSigner == null && account.settings.optimizeMediaOnUpload.value
return try {
val result =
BlossomUploader()
@@ -211,27 +219,93 @@ class UploadOrchestrator {
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
// Use a t=media token when optimizing via /media, otherwise a plain
// t=upload token. Tokens are intentionally NOT server-scoped on the
// upload path: some servers reject an upload whose auth carries a
// `server` tag, and upload-token replay is not the threat scoping
// guards against (delete tokens are — those stay scoped).
httpAuth =
if (forcedSigner != null) {
{ hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) }
} else {
account::createBlossomUploadAuth
when {
forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) }
useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt) }
else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt) }
},
context = context,
useMediaEndpoint = useMedia,
)
verifyHeader(
uploadResult = result,
localContentType = contentType,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
originalHash = originalHash,
originalContentType = contentTypeForResult,
)
val finalState =
verifyHeader(
uploadResult = result,
localContentType = contentType,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
originalHash = originalHash,
originalContentType = contentTypeForResult,
)
// BUD-04: replicate the blob to the user's other Blossom servers for redundancy.
// Fire-and-forget on the account scope AFTER the upload is finished: mirroring is
// pure background redundancy, so it must never delay, alter, or fail the upload the
// user already completed, and must not touch the on-screen progress state.
if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) {
account.scope.launch(Dispatchers.IO) {
try {
mirrorToOtherServers(result, serverBaseUrl, account)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Background mirror failed", e)
}
}
}
finalState
} catch (_: SignerExceptions.ReadOnlyException) {
error(R.string.login_with_a_private_key_to_be_able_to_upload)
} catch (e: BlossomPaymentException) {
// BUD-07: the server wants payment before it will store the blob.
error(R.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
error(R.string.failed_to_upload_media, e.message?.ifBlank { null } ?: e.javaClass.simpleName)
}
}
/**
* BUD-04 mirror fan-out: asks every *other* Blossom server in the account's
* kind-10063 list to pull the freshly-uploaded blob from [result]'s URL. Runs
* in the background after the upload is finished (see caller), so it never
* delays the user's post or touches the upload progress UI; per-server failures
* are swallowed. Requires the blob's sha256 so server B can verify the download.
*/
private suspend fun mirrorToOtherServers(
result: MediaUploadResult,
primaryServerBaseUrl: String,
account: Account,
) {
val sourceUrl = result.url ?: return
val hash = result.sha256 ?: sourceUrl.substringAfterLast('/').substringBefore('.')
if (hash.length != 64) return
// Only the user's *explicitly configured* kind-10063 servers (flow), NOT the
// DEFAULT_MEDIA_SERVERS fallback that hostNameFlow injects — we must never fan
// uploads out to public defaults the user never opted into.
val primaryDomain = BlossomServerUrl.domain(primaryServerBaseUrl)
val targets =
account.blossomServers.flow.value
.filter { BlossomServerUrl.domain(it) != primaryDomain }
.distinct()
if (targets.isEmpty()) return
targets.forEach { target ->
try {
val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash").toAuthorizationHeader()
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
.mirror(sourceUrl, target, auth)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Failed to mirror $hash to $target", e)
}
}
}
@@ -0,0 +1,155 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/** Aggregate progress of a running "sync all my blobs to all my servers" sweep. */
@Immutable
data class BlossomSyncState(
val total: Int,
val done: Int,
val failed: Int,
val running: Boolean,
) {
val succeeded get() = done - failed
val fraction get() = if (total == 0) 0f else done.toFloat() / total
}
/** One completed mirror step, streamed so an open manager screen can flip its pill live. */
@Immutable
data class BlossomMirrorResult(
val hash: HexKey,
val server: String,
val ok: Boolean,
)
/**
* App-level BUD-04 mirror queue, modeled on the PoW publish queue: it runs on the
* application IO scope (via [Amethyst.instance]) so a sweep keeps going while the
* user navigates the app, and exposes [state] for a floating progress banner mounted
* at the navigation root. Servers that require payment are skipped (counted as
* failed) — those are paid for individually from the manager screen.
*/
class BlossomMirrorQueue(
private val scope: CoroutineScope,
/** Invoked (on the foreground thread that called [start]) when a sweep begins, to start the FGS. */
private val onActive: () -> Unit = {},
) {
data class Task(
val hash: HexKey,
val sourceUrl: String,
val size: Long?,
val targets: List<String>,
)
private val _state = MutableStateFlow<BlossomSyncState?>(null)
val state: StateFlow<BlossomSyncState?> = _state.asStateFlow()
private val _results = MutableSharedFlow<BlossomMirrorResult>(extraBufferCapacity = 128)
val results: SharedFlow<BlossomMirrorResult> = _results.asSharedFlow()
private var job: Job? = null
val isRunning get() = _state.value?.running == true
/** Enqueue a sweep. No-op if one is already running or there's nothing to do. */
fun start(
account: Account,
tasks: List<Task>,
) {
if (isRunning) return
val work = tasks.flatMap { t -> t.targets.map { t to it } }
if (work.isEmpty()) return
// Publish the active state and start the foreground service synchronously (we're on the
// foreground thread here, which is what dataSync FGS starts require) before the sweep runs.
_state.value = BlossomSyncState(total = work.size, done = 0, failed = 0, running = true)
onActive()
// Parallelize ACROSS servers but stay serial WITHIN a server, so every server gets a
// steady one-at-a-time stream (no hammering / rate-limit storms) while all of the user's
// servers work at once. Wall-clock ≈ the slowest single server's queue, not the sum.
val byServer: Map<String, List<Task>> = work.groupBy({ it.second }, { it.first })
job =
scope.launch {
byServer
.map { (target, serverTasks) ->
async {
for (task in serverTasks) {
val ok = mirrorOne(account, task, target)
_results.tryEmit(BlossomMirrorResult(task.hash, target, ok))
_state.update { it?.copy(done = it.done + 1, failed = it.failed + if (ok) 0 else 1) }
}
}
}.awaitAll()
_state.update { it?.copy(running = false) }
}
}
private suspend fun mirrorOne(
account: Account,
task: Task,
target: String,
): Boolean =
try {
val auth = account.createBlossomUploadAuth(task.hash, task.size ?: 0L, "Mirror ${task.hash}").toAuthorizationHeader()
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
.mirror(task.sourceUrl, target, auth)
true
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("BlossomMirrorQueue", "mirror ${task.hash} -> $target failed", e)
false
}
/** Cancel a running sweep and clear the banner. */
fun cancel() {
job?.cancel()
job = null
_state.value = null
}
/** Dismiss the finished-summary banner (no effect while still running). */
fun dismiss() {
if (!isRunning) _state.value = null
}
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
/**
* Settles a BUD-07 [BlossomPaymentRequired] challenge so a blocked upload/mirror can
* be retried. Reuses the account's existing NIP-47 (Nostr Wallet Connect) lightning
* path — this handler never touches keys or moves funds itself, it only asks the
* user's connected wallet to pay the invoice and returns the resulting preimage as
* the [BlossomPaymentProof].
*
* Cashu-only servers (`X-Cashu`, no `X-Lightning`) are not yet supported here; the
* caller should surface that a lightning wallet is required.
*/
object BlossomPaymentHandler {
/** True when this account has a wallet we can pay the lightning invoice with. */
fun canPay(
account: Account,
payment: BlossomPaymentRequired,
): Boolean = payment.lightning != null && account.nip47SignerState.hasWalletConnectSetup()
/** The invoice amount in sats, for display in a confirmation prompt. */
fun amountSats(payment: BlossomPaymentRequired): Long? =
payment.lightning?.let {
runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()
}
/**
* Pays the challenge's BOLT-11 invoice via NWC and returns the proof, or null if
* there is no payable invoice, no wallet, or the wallet didn't confirm in time.
*/
suspend fun pay(
account: Account,
payment: BlossomPaymentRequired,
): BlossomPaymentProof? {
val invoice = payment.lightning ?: return null
if (!account.nip47SignerState.hasWalletConnectSetup()) return null
val preimageResult = CompletableDeferred<String?>()
try {
account.sendZapPaymentRequestFor(invoice, null) { response ->
// CompletableDeferred.complete is idempotent, so extra callbacks are harmless.
preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage)
}
} catch (e: Exception) {
Log.w("BlossomPayment", "Failed to send NWC payment request", e)
return null
}
val preimage = withTimeoutOrNull(90_000) { preimageResult.await() } ?: return null
return BlossomPaymentProof(lightningPreimage = preimage)
}
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom
import android.content.Context
import android.content.pm.ServiceInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Foreground service that keeps the BUD-04 "sync all" sweep ([BlossomMirrorQueue]) running
* while the app is backgrounded. Uses the Android 14+ `dataSync` type — the correct type for
* an upload/download/sync operation (a multi-file mirror routinely exceeds the `shortService`
* ~3-minute budget that PoW mining uses).
*
* `dataSync` must be started while the app is foreground; "Sync all" is user-initiated from the
* manager, so that holds. All of the notification + lifecycle lives in the shared
* [FlowProgressForegroundService]; this maps the sweep's [BlossomSyncState] onto the card.
*/
class BlossomSyncForegroundService : FlowProgressForegroundService<BlossomSyncState?>() {
override val fgsType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
override val channelId = CHANNEL_ID
override val channelNameRes = R.string.blossom_sync_channel_name
override val channelDescRes = R.string.blossom_sync_channel_description
override val notificationId = NOTIFICATION_ID
override val cancelAction = ACTION_CANCEL
override val cancelLabelRes = R.string.blossom_sync_cancel
override fun state() = Amethyst.instance.blossomMirrorQueue.state
override fun isActive(value: BlossomSyncState?) = value?.running == true
override fun cancelAll() = Amethyst.instance.blossomMirrorQueue.cancel()
// State emits on every mirror step (including currentHost changes), so no clock refresh.
override val refreshMs: Long? = null
override fun render(value: BlossomSyncState?): Content {
if (value == null) return Content(stringRes(this, R.string.blossom_syncing), null, Bar.Indeterminate)
val text =
buildString {
append("${value.done} / ${value.total}")
if (value.failed > 0) append(" · ${value.failed} failed")
}
return Content(stringRes(this, R.string.blossom_syncing), text, Bar.Determinate(value.fraction.toDouble()))
}
companion object {
private const val TAG = "BlossomSyncFgs"
private const val CHANNEL_ID = "blossom_sync"
private const val NOTIFICATION_ID = 0x424C4F // "BLO"
private const val ACTION_CANCEL = "com.vitorpamplona.amethyst.blossom.SYNC_CANCEL"
/**
* Started when a sweep begins (from the foreground); stops itself when it finishes.
* No `running` de-dup: a sweep starts this exactly once (via [BlossomMirrorQueue.onActive]),
* and a redundant start would just route to a cheap onStartCommand — whereas a stale
* "already running" flag could leave a fresh sweep with no foreground protection.
*/
fun start(context: Context) {
FlowProgressForegroundService.start(context, BlossomSyncForegroundService::class.java, TAG)
}
}
}
@@ -26,6 +26,7 @@ import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
@@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import com.vitorpamplona.quartz.utils.RandomInstance
@@ -79,6 +81,7 @@ class BlossomUploader {
okHttpClient: (String) -> OkHttpClient,
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
context: Context,
useMediaEndpoint: Boolean = false,
onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null,
): MediaUploadResult {
checkNotInMainThread()
@@ -115,6 +118,7 @@ class BlossomUploader {
okHttpClient,
httpAuth,
context,
useMediaEndpoint,
onProgress,
)
}.mergeLocalMetadata(localMetadata)
@@ -132,6 +136,7 @@ class BlossomUploader {
okHttpClient: (String) -> OkHttpClient,
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
context: Context,
useMediaEndpoint: Boolean = false,
onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null,
): MediaUploadResult {
checkNotInMainThread()
@@ -142,7 +147,8 @@ class BlossomUploader {
MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: extensionFromMimeType(it)
} ?: ""
val apiUrl = BlossomServerUrl.upload(serverBaseUrl)
// BUD-05: /media asks the server to optimize; /upload stores the exact bytes.
val apiUrl = if (useMediaEndpoint) BlossomServerUrl.media(serverBaseUrl) else BlossomServerUrl.upload(serverBaseUrl)
val client = okHttpClient(apiUrl)
val requestBuilder = Request.Builder()
@@ -200,6 +206,10 @@ class BlossomUploader {
response.body.use { body ->
convertToMediaResult(parseResults(body.string()))
}
} else if (response.code == 402) {
// BUD-07: paid server. Surface the payment challenge so the caller can
// tell the user (auto-settlement via the NIP-60 wallet is a follow-up).
throw BlossomPaymentException(serverBaseUrl, BlossomPaymentRequired.fromHeaders { response.headers[it] })
} else {
val errorMessage = response.headers.get(BlossomServerUrl.REASON_HEADER)
@@ -40,8 +40,10 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -60,6 +62,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayDragState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.draggableRelayItem
@@ -91,6 +95,7 @@ private val MonogramColors =
fun AllMediaBody(
blossomServersViewModel: BlossomServersViewModel,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle()
@@ -156,6 +161,11 @@ fun AllMediaBody(
)
}
item {
SectionLabel(title = stringRes(id = R.string.media_servers_upload_section))
UploadBehaviorSection(accountViewModel, nav)
}
item {
SectionLabel(title = stringRes(id = R.string.media_servers_cache_section))
MediaCacheSection(accountViewModel)
@@ -167,6 +177,97 @@ fun AllMediaBody(
}
}
/**
* Upload-side Blossom controls: mirror uploads across the user's servers (BUD-04),
* optimize via `/media` (BUD-05), and a shortcut into the blob manager.
*/
@Composable
private fun UploadBehaviorSection(
accountViewModel: AccountViewModel,
nav: INav,
) {
val mirror by accountViewModel.account.settings.mirrorUploadsToAllServers
.collectAsStateWithLifecycle()
val optimize by accountViewModel.account.settings.optimizeMediaOnUpload
.collectAsStateWithLifecycle()
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainer),
) {
UploadToggleRow(
title = stringRes(id = R.string.blossom_mirror_uploads),
caption = stringRes(id = R.string.blossom_mirror_uploads_caption),
checked = mirror,
onCheckedChange = { accountViewModel.account.settings.changeMirrorUploadsToAllServers(it) },
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 14.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
UploadToggleRow(
title = stringRes(id = R.string.blossom_optimize_media),
caption = stringRes(id = R.string.blossom_optimize_media_caption),
checked = optimize,
onCheckedChange = { accountViewModel.account.settings.changeOptimizeMediaOnUpload(it) },
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 14.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.ManageBlossomBlobs) }
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(id = R.string.my_blossom_data),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Icon(
symbol = MaterialSymbols.Storage,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun UploadToggleRow(
title: String,
caption: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
Text(
text = caption,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
/** Compact section header: an accent label with an optional gray caption below. */
@Composable
private fun SectionLabel(
@@ -92,6 +92,7 @@ fun MediaServersScaffold(
AllMediaBody(
blossomServersViewModel = blossomServersViewModel,
accountViewModel = accountViewModel,
nav = nav,
modifier =
Modifier
.fillMaxSize()
@@ -0,0 +1,501 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip56Reports.ReportType
@Composable
fun BlossomBlobManagerScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: BlossomBlobManagerViewModel = viewModel()
vm.init(accountViewModel)
LaunchedEffect(accountViewModel) { vm.refresh() }
val blobs by vm.blobs.collectAsStateWithLifecycle()
val loading by vm.isLoading.collectAsStateWithLifecycle()
val error by vm.error.collectAsStateWithLifecycle()
val pendingPayment by vm.pendingPayment.collectAsStateWithLifecycle()
pendingPayment?.let { pending ->
BlossomPaymentDialog(
host = pending.targetHost,
amountSats = pending.amountSats,
reason = pending.payment.reason,
onConfirm = { vm.confirmPendingPayment() },
onDismiss = { vm.cancelPendingPayment() },
)
}
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
title = { Text(stringRes(R.string.my_blossom_data)) },
showBackButton = nav.canPop(),
popBack = { nav.popBack() },
actions = {
IconButton(onClick = { vm.refresh() }, enabled = !loading) {
if (loading) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
} else {
Icon(symbol = MaterialSymbols.Sync, contentDescription = stringRes(R.string.blossom_refresh))
}
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding(),
),
) {
when {
loading && blobs.isEmpty() -> CenteredState { CircularProgressIndicator() }
error != null && blobs.isEmpty() ->
CenteredState {
StatusGlyph(MaterialSymbols.Warning, MaterialTheme.colorScheme.error)
Spacer(Modifier.height(12.dp))
Text(error ?: "", color = MaterialTheme.colorScheme.grayText)
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = { vm.refresh() }) { Text(stringRes(R.string.retry)) }
}
blobs.isEmpty() ->
CenteredState {
StatusGlyph(MaterialSymbols.Storage, MaterialTheme.colorScheme.grayText)
Spacer(Modifier.height(12.dp))
Text(
stringRes(R.string.manage_stored_files_empty),
color = MaterialTheme.colorScheme.grayText,
)
}
else ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (blobs.any { it.hasMissing }) {
item { SyncAllBanner(onSyncAll = { vm.syncAll() }) }
}
items(blobs, key = { it.hash }) { row ->
BlobCard(row, vm)
}
}
}
}
}
}
@Composable
private fun CenteredState(content: @Composable () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { content() }
}
@Composable
private fun StatusGlyph(
symbol: MaterialSymbol,
tint: Color,
) {
Box(
modifier =
Modifier
.size(72.dp)
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(34.dp), tint = tint)
}
}
@Composable
private fun SyncAllBanner(onSyncAll: () -> Unit) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(stringRes(R.string.blossom_sync_gaps), style = MaterialTheme.typography.bodyMedium)
}
FilledTonalButton(onClick = onSyncAll) {
Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.blossom_sync_all))
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BlobCard(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
) {
var menuOpen by remember { mutableStateOf(false) }
var reportOpen by remember { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Header: thumbnail / file glyph + hash + overflow menu.
Row(verticalAlignment = Alignment.CenterVertically) {
BlobThumbnail(row)
Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(
text = row.hash.take(12) + "…" + row.hash.takeLast(6),
style = MaterialTheme.typography.titleSmall,
fontFamily = FontFamily.Monospace,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
Text(
text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(symbol = MaterialSymbols.MoreVert, contentDescription = null)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
if (row.url != null) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy)) },
leadingIcon = { MenuIcon(MaterialSymbols.ContentCopy) },
onClick = {
menuOpen = false
clipboard.setText(AnnotatedString(row.url))
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_open)) },
leadingIcon = { MenuIcon(MaterialSymbols.AutoMirrored.OpenInNew) },
onClick = {
menuOpen = false
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) }
},
)
}
if (row.hasPresent) {
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_report)) },
leadingIcon = { MenuIcon(MaterialSymbols.Report) },
onClick = {
menuOpen = false
reportOpen = true
},
)
HorizontalDivider()
row.presentServers.forEach { server ->
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_delete_from_host, vm.hostOf(server))) },
leadingIcon = { MenuIcon(MaterialSymbols.Delete, MaterialTheme.colorScheme.error) },
onClick = {
menuOpen = false
vm.delete(row.hash, server)
},
)
}
}
}
}
}
// Per-server presence pills (green = has it, grey = missing, spinner = working).
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
row.servers.forEach { ServerPill(it) }
}
// Primary CTA: fill the gaps.
if (row.hasMissing && row.url != null) {
FilledTonalButton(
onClick = { vm.mirrorToMissing(row) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.blossom_mirror_to_missing))
}
}
}
if (reportOpen) {
BlossomReportDialog(row = row, vm = vm, onDismiss = { reportOpen = false })
}
}
@Composable
private fun BlobThumbnail(row: BlobRow) {
val shape = RoundedCornerShape(12.dp)
if (row.url != null && row.type?.startsWith("image/") == true) {
AsyncImage(
model = row.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp).clip(shape),
)
} else {
Box(
modifier = Modifier.size(48.dp).clip(shape).background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
val glyph = if (row.type?.startsWith("video/") == true) MaterialSymbols.Download else MaterialSymbols.Storage
Icon(
symbol = glyph,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
@Composable
private fun ServerPill(presence: ServerPresence) {
val present = presence.state == PresenceState.PRESENT
val pending = presence.state == PresenceState.PENDING
val accent = MaterialTheme.colorScheme.allGoodColor
val bg =
if (present) accent.copy(alpha = 0.12f) else MaterialTheme.colorScheme.surfaceContainerHighest
Row(
modifier = Modifier.clip(CircleShape).background(bg).padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (pending) {
CircularProgressIndicator(
modifier = Modifier.size(9.dp),
strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.primary,
)
} else {
val dot = if (present) accent else MaterialTheme.colorScheme.grayText
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(dot))
}
Text(
text = presence.host,
style = MaterialTheme.typography.labelMedium,
color = if (present) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.grayText,
)
}
}
@Composable
private fun MenuIcon(
symbol: MaterialSymbol,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint)
}
private fun humanBytes(bytes: Long): String =
when {
bytes >= 1_000_000 -> "${bytes / 1_000_000} MB"
bytes >= 1_000 -> "${bytes / 1_000} KB"
else -> "$bytes B"
}
@Composable
private fun BlossomPaymentDialog(
host: String,
amountSats: Long?,
reason: String?,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.allGoodColor) },
title = { Text(stringRes(R.string.blossom_payment_title)) },
text = {
Text(
text =
listOfNotNull(
stringRes(R.string.blossom_payment_message, host),
reason,
).joinToString("\n\n"),
)
},
confirmButton = {
FilledTonalButton(onClick = onConfirm) {
Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(
if (amountSats != null) {
pluralStringResource(R.plurals.blossom_pay_sats, amountSats.toInt(), amountSats.toInt())
} else {
stringRes(R.string.blossom_pay)
},
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
@Composable
private fun BlossomReportDialog(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
onDismiss: () -> Unit,
) {
var comment by remember { mutableStateOf("") }
var typeMenuOpen by remember { mutableStateOf(false) }
var type by remember { mutableStateOf(ReportType.OTHER) }
// Report to the first server that actually holds the blob.
val server = row.presentServers.firstOrNull() ?: return
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(symbol = MaterialSymbols.Report, contentDescription = null) },
title = { Text(stringRes(R.string.blossom_report_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Box {
OutlinedButton(onClick = { typeMenuOpen = true }, modifier = Modifier.fillMaxWidth()) {
Text(type.code)
}
DropdownMenu(expanded = typeMenuOpen, onDismissRequest = { typeMenuOpen = false }) {
ReportType.entries.forEach { rt ->
DropdownMenuItem(
text = { Text(rt.code) },
onClick = {
type = rt
typeMenuOpen = false
},
)
}
}
}
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
label = { Text(stringRes(R.string.blossom_report_comment_hint)) },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
FilledTonalButton(onClick = {
vm.report(row.hash, server, type, comment)
onDismiss()
}) { Text(stringRes(R.string.blossom_send)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
@@ -0,0 +1,427 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomPaymentHandler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
import com.vitorpamplona.quartz.nipB7Blossom.BlossomReport
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
/** Whether one of the user's servers holds a blob, or an operation on it is in flight. */
enum class PresenceState {
/** Confirmed present (green). */
PRESENT,
/** Confirmed absent (grey). */
MISSING,
/** An operation is in flight — first-load HEAD probe, a mirror, or a delete (spinner). */
PENDING,
}
@Immutable
data class ServerPresence(
val server: String,
val host: String,
val state: PresenceState,
)
/**
* One stored blob, plus the state of each of the user's Blossom servers for it —
* the "seen files per server" view (BUD-01 HEAD / BUD-02 list).
*/
@Immutable
data class BlobRow(
val hash: HexKey,
val url: String?,
val size: Long?,
val type: String?,
val servers: List<ServerPresence>,
) {
val presentServers get() = servers.filter { it.state == PresenceState.PRESENT }.map { it.server }
val missingServers get() = servers.filter { it.state == PresenceState.MISSING }.map { it.server }
val hasPresent get() = servers.any { it.state == PresenceState.PRESENT }
val hasMissing get() = servers.any { it.state == PresenceState.MISSING }
val presentCount get() = servers.count { it.state == PresenceState.PRESENT }
}
/** A BUD-07 payment prompt raised while mirroring [hash] to [target]. */
@Immutable
data class PendingMirrorPayment(
val hash: HexKey,
val sourceUrl: String,
val target: String,
val targetHost: String,
val payment: BlossomPaymentRequired,
val amountSats: Long?,
)
/**
* Backs the Blossom blob-manager screen. For the active account it fans a
* `GET /list/<pubkey>` (BUD-02) across every server in the user's kind-10063 list
* in parallel, shows the result immediately, then backfills only the servers that
* do NOT implement `/list` with parallel `HEAD /<sha256>` probes (BUD-01) — a server
* that returned a list already reports its full holdings, so no probe is needed for
* it. Delete (BUD-02), mirror-to-missing (BUD-04) and report (BUD-09) update the
* affected pill in place (spinner → green/grey) instead of reloading the screen.
*/
@Stable
class BlossomBlobManagerViewModel : ViewModel() {
private lateinit var account: Account
private val _blobs = MutableStateFlow<List<BlobRow>>(emptyList())
val blobs = _blobs.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error = _error.asStateFlow()
private val _pendingPayment = MutableStateFlow<PendingMirrorPayment?>(null)
val pendingPayment = _pendingPayment.asStateFlow()
private var resultCollectorStarted = false
fun init(accountViewModel: AccountViewModel) {
this.account = accountViewModel.account
// Reflect the app-level sync sweep's per-server results onto the pills, so an
// open manager turns dots green live even though the work runs in the background.
if (!resultCollectorStarted) {
resultCollectorStarted = true
viewModelScope.launch {
Amethyst.instance.blossomMirrorQueue.results.collect { r ->
setServerState(r.hash, r.server, if (r.ok) PresenceState.PRESENT else PresenceState.MISSING)
}
}
}
}
private fun clientFor(server: String) = BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(server))
// The user's explicitly configured kind-10063 servers (empty if they never set
// a list) — not the DEFAULT_MEDIA_SERVERS fallback, so the matrix reflects the
// servers the user actually chose.
private fun servers(): List<String> =
account.blossomServers.flow.value
.distinct()
private var refreshJob: Job? = null
fun refresh() {
// Cancel any in-flight load so two quick refreshes can't interleave their writes
// or fight over _isLoading.
refreshJob?.cancel()
refreshJob =
viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true
_error.value = null
try {
loadMatrix()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("BlossomBlobManager", "Failed to load blob list", e)
_error.value = e.message?.ifBlank { null } ?: e.javaClass.simpleName
} finally {
_isLoading.value = false
}
}
}
private suspend fun loadMatrix() {
val pubkey = account.signer.pubKey
val servers = servers()
if (servers.isEmpty()) {
_blobs.value = emptyList()
return
}
// Phase 1 — /list every server in parallel. A null result means the server
// doesn't implement /list (or errored) and needs HEAD backfill in phase 2.
val listed: List<Pair<String, List<BlossomUploadResult>?>> =
coroutineScope {
servers
.map { server ->
async {
server to
try {
val auth = account.createBlossomListAuth("List blobs").toAuthorizationHeader()
clientFor(server).list(server, pubkey, auth)
} catch (e: Exception) {
Log.w("BlossomBlobManager", "list failed on $server", e)
null
}
}
}.awaitAll()
}
val meta = HashMap<HexKey, BlobMeta>()
val hashesByServer = HashMap<String, Set<HexKey>>()
val listCapable = HashSet<String>()
listed.forEach { (server, blobs) ->
if (blobs != null) {
listCapable.add(server)
hashesByServer[server] = blobs.mapNotNull { it.sha256 }.toSet()
blobs.forEach { d -> d.sha256?.let { meta.putIfAbsent(it, BlobMeta(d.url, d.size, d.type)) } }
}
}
val allHashes = meta.keys.toList()
fun rows(head: Map<Pair<String, HexKey>, Boolean> = emptyMap()): List<BlobRow> =
allHashes
.map { hash ->
val presences =
servers.map { server ->
val state =
when {
server in listCapable ->
if (hash in hashesByServer[server].orEmpty()) PresenceState.PRESENT else PresenceState.MISSING
else ->
head[server to hash]?.let { if (it) PresenceState.PRESENT else PresenceState.MISSING }
?: PresenceState.PENDING
}
ServerPresence(server, hostOf(server), state)
}
val m = meta[hash]
BlobRow(hash, m?.url, m?.size, m?.type, presences)
}.sortedByDescending { it.presentCount }
// Show the /list result right away; the non-list servers appear as spinning
// pills until their HEAD probe lands.
_blobs.value = rows()
_isLoading.value = false
// Phase 2 — HEAD-probe ONLY the non-list servers, for the known hashes. Bounded so a
// user with hundreds of blobs on a non-/list server doesn't spawn hundreds of probes
// at once; OkHttp still caps per-host, this caps the coroutine/allocation breadth.
val nonListServers = servers.filter { it !in listCapable }
if (nonListServers.isNotEmpty() && allHashes.isNotEmpty()) {
val limiter = Semaphore(MAX_HEAD_PROBES)
val head =
coroutineScope {
nonListServers
.flatMap { server ->
allHashes.map { hash ->
async { limiter.withPermit { (server to hash) to clientFor(server).has(hash, server) } }
}
}.awaitAll()
}.toMap()
_blobs.value = rows(head)
}
}
private fun currentRow(hash: HexKey) = _blobs.value.firstOrNull { it.hash == hash }
private fun setServerState(
hash: HexKey,
server: String,
state: PresenceState,
) {
// Atomic read-modify-write: the app-level sync results collector (Main) and the
// per-row delete/mirror actions (IO) both mutate _blobs concurrently, so a plain
// `_blobs.value = _blobs.value.map{}` would lose updates.
_blobs.update { list ->
list.map { row ->
if (row.hash != hash) {
row
} else {
row.copy(servers = row.servers.map { if (it.server == server) it.copy(state = state) else it })
}
}
}
}
/** BUD-02 delete: remove [hash] from a single [server]; the pill spins then goes grey. */
fun delete(
hash: HexKey,
server: String,
) {
viewModelScope.launch(Dispatchers.IO) {
setServerState(hash, server, PresenceState.PENDING)
val ok =
try {
val auth = account.createBlossomDeleteAuth(hash, "Delete blob").toAuthorizationHeader()
clientFor(server).delete(hash, server, auth)
} catch (e: Exception) {
Log.w("BlossomBlobManager", "delete failed on $server", e)
false
}
setServerState(hash, server, if (ok) PresenceState.MISSING else PresenceState.PRESENT)
// Drop the row entirely once it's gone from every server.
_blobs.update { list -> if (list.firstOrNull { it.hash == hash }?.hasPresent == false) list.filter { it.hash != hash } else list }
}
}
/** BUD-04: mirror a blob to every server that doesn't have it; each pill spins then turns green. */
fun mirrorToMissing(row: BlobRow) {
val source = row.url ?: return
val targets = currentRow(row.hash)?.missingServers ?: row.missingServers
if (targets.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
for (target in targets) {
setServerState(row.hash, target, PresenceState.PENDING)
try {
mirrorOne(source, row.hash, row.size, target, null)
setServerState(row.hash, target, PresenceState.PRESENT)
} catch (e: BlossomPaymentException) {
setServerState(row.hash, target, PresenceState.MISSING)
if (BlossomPaymentHandler.canPay(account, e.payment)) {
_pendingPayment.value =
PendingMirrorPayment(row.hash, source, target, hostOf(target), e.payment, BlossomPaymentHandler.amountSats(e.payment))
return@launch
}
Log.w("BlossomBlobManager", "mirror to $target needs unsupported payment", e)
} catch (e: Exception) {
setServerState(row.hash, target, PresenceState.MISSING)
Log.w("BlossomBlobManager", "mirror to $target failed", e)
}
}
}
}
/**
* BUD-04 sweep: hand the whole "fill every gap" job to the app-level
* [BlossomMirrorQueue] so it keeps running (with a floating progress banner) as
* the user navigates away. The pills we're about to fill go straight to a spinner;
* the queue's [results] stream (collected in [init]) flips each to green/grey as it
* lands, so an open manager stays in sync with the background sweep.
*/
fun syncAll() {
val tasks =
_blobs.value
.filter { it.hasMissing && it.url != null }
.map { BlossomMirrorQueue.Task(it.hash, it.url!!, it.size, it.missingServers) }
if (tasks.isEmpty()) return
_blobs.update { list ->
list.map { row ->
if (!row.hasMissing) {
row
} else {
row.copy(servers = row.servers.map { if (it.state == PresenceState.MISSING) it.copy(state = PresenceState.PENDING) else it })
}
}
}
Amethyst.instance.blossomMirrorQueue.start(account, tasks)
}
private suspend fun mirrorOne(
source: String,
hash: HexKey,
size: Long?,
target: String,
proof: BlossomPaymentProof?,
) {
val auth = account.createBlossomUploadAuth(hash, size ?: 0L, "Mirror $hash").toAuthorizationHeader()
clientFor(target).mirror(source, target, auth, proof)
}
/** User confirmed the BUD-07 prompt: pay via the wallet, retry that server, then continue. */
fun confirmPendingPayment() {
val pending = _pendingPayment.value ?: return
_pendingPayment.value = null
viewModelScope.launch(Dispatchers.IO) {
setServerState(pending.hash, pending.target, PresenceState.PENDING)
val proof = BlossomPaymentHandler.pay(account, pending.payment)
if (proof == null) {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
_error.value = "Payment failed or was not confirmed by the wallet."
return@launch
}
try {
mirrorOne(pending.sourceUrl, pending.hash, currentRow(pending.hash)?.size, pending.target, proof)
setServerState(pending.hash, pending.target, PresenceState.PRESENT)
} catch (e: Exception) {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
Log.w("BlossomBlobManager", "paid mirror to ${pending.target} failed", e)
}
// Continue with any remaining missing servers (which may prompt again).
currentRow(pending.hash)?.let { mirrorToMissing(it) }
}
}
fun cancelPendingPayment() {
_pendingPayment.value = null
}
/** BUD-09: report a blob to a server as problematic content. */
fun report(
hash: HexKey,
server: String,
type: ReportType,
comment: String,
onDone: (Boolean) -> Unit = {},
) {
viewModelScope.launch(Dispatchers.IO) {
val ok =
try {
val event = account.signer.sign(BlossomReport.build(hash, type, account.signer.pubKey, comment))
clientFor(server).report(server, event.toJson())
} catch (e: Exception) {
Log.w("BlossomBlobManager", "report failed on $server", e)
false
}
withContext(Dispatchers.Main) { onDone(ok) }
}
}
fun hostOf(serverBaseUrl: String): String = BlossomServerUrl.domain(serverBaseUrl)
private data class BlobMeta(
val url: String?,
val size: Long?,
val type: String?,
)
companion object {
/** Cap on concurrent HEAD probes during the /list backfill. */
private const val MAX_HEAD_PROBES = 8
}
}
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
/**
* App-wide floating banner for the BUD-04 "sync all" sweep, mounted at the navigation
* root (a sibling of the mining/broadcast banners) so it floats over every screen and
* survives navigation. Observes the app-level [Amethyst.instance.blossomMirrorQueue].
*/
@Composable
fun DisplayBlossomSyncProgress() {
val queue = Amethyst.instance.blossomMirrorQueue
val state by queue.state.collectAsStateWithLifecycle()
// Retain the last non-null value so the slide-out exit still has content to draw when
// state clears to null on cancel/dismiss.
var lastShown by remember { mutableStateOf<BlossomSyncState?>(null) }
LaunchedEffect(state) { state?.let { lastShown = it } }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
AnimatedVisibility(
visible = state != null,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier =
Modifier
.navigationBarsPadding()
.padding(start = 12.dp, end = 12.dp, bottom = 116.dp)
.widthIn(max = 560.dp),
) {
val s = lastShown ?: return@AnimatedVisibility
Surface(
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 3.dp,
shadowElevation = 6.dp,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = if (s.running) MaterialSymbols.CloudUpload else MaterialSymbols.Check,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (s.running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.allGoodColor,
)
Text(
text = if (s.running) stringRes(R.string.blossom_syncing) else stringRes(R.string.blossom_sync_done),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 10.dp),
)
IconButton(
onClick = { if (s.running) queue.cancel() else queue.dismiss() },
modifier = Modifier.size(28.dp),
) {
Icon(symbol = MaterialSymbols.Close, contentDescription = null, modifier = Modifier.size(18.dp))
}
}
LinearProgressIndicator(
progress = { s.fraction },
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
)
Text(
text =
buildString {
append("${s.done} / ${s.total}")
if (s.failed > 0) append(" · ${s.failed} failed")
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
}
@@ -56,6 +56,8 @@ import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAler
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomBlobManagerScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DisplayBlossomSyncProgress
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
import com.vitorpamplona.amethyst.ui.call.CallActivity
@@ -350,6 +352,7 @@ fun AppNavigation(
DisplayCrashMessages(accountViewModel, nav)
DisplayResourceUsageAlert(accountViewModel, nav)
DisplayBroadcastProgress(accountViewModel)
DisplayBlossomSyncProgress()
ObserveIncomingCalls(accountViewModel)
}
@@ -561,6 +564,7 @@ fun BuildNavigation(
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ManageBlossomBlobs> { BlossomBlobManagerScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditNestsServers> {
com.vitorpamplona.amethyst.ui.actions.nestsServers
.NestsServersScreen(accountViewModel, nav)
@@ -46,6 +46,7 @@ enum class NavBarItem {
DRAFTS,
SCHEDULED_POSTS,
INTEREST_SETS,
BLOSSOM_DATA,
EMOJI_PACKS,
WALLET,
NOSTR_SIGNER,
@@ -182,6 +183,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.AutoAwesome,
resolveRoute = { Route.EditFavoriteAlgoFeeds },
),
NavBarItem.BLOSSOM_DATA to
NavBarItemDef(
id = NavBarItem.BLOSSOM_DATA,
labelRes = R.string.my_blossom_data,
icon = MaterialSymbols.Storage,
resolveRoute = { Route.ManageBlossomBlobs },
),
NavBarItem.EMOJI_PACKS to
NavBarItemDef(
id = NavBarItem.EMOJI_PACKS,
@@ -449,6 +457,7 @@ val DrawerYouItems: List<NavBarItem> =
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
@@ -502,6 +511,7 @@ val BottomBarCategories: List<NavBarCategory> =
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.FAVORITE_ALGO_FEEDS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
@@ -465,6 +465,8 @@ sealed class Route {
@Serializable object EditMediaServers : Route()
@Serializable object ManageBlossomBlobs : Route()
@Serializable object EditNestsServers : Route()
@Serializable object EditFavoriteAlgoFeeds : Route()
@@ -168,6 +168,7 @@ private fun PreloadFor(
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
+33
View File
@@ -1545,6 +1545,39 @@
<string name="no_blossom_server_message">You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓</string>
<string name="media_servers_upload_section">Upload behaviour</string>
<string name="blossom_mirror_uploads">Mirror uploads</string>
<string name="blossom_mirror_uploads_caption">After uploading, copy the file to your other Blossom servers so it stays available if one goes offline.</string>
<string name="blossom_optimize_media">Optimize media on the server</string>
<string name="blossom_optimize_media_caption">Upload through the server\'s /media endpoint so it can strip metadata and compress the file. The stored file may differ from the original.</string>
<string name="manage_stored_files">Manage stored files</string>
<string name="my_blossom_data">My Blossom Files</string>
<string name="blossom_refresh">Refresh</string>
<string name="blossom_sync_all">Sync all</string>
<string name="blossom_sync_gaps">Some of your files aren\'t on all your servers yet.</string>
<string name="blossom_syncing">Copying your files across servers…</string>
<string name="blossom_sync_done">Sync complete</string>
<string name="blossom_sync_cancel">Cancel</string>
<string name="blossom_sync_channel_name">Blossom sync</string>
<string name="blossom_sync_channel_description">Shows progress while copying your files across your Blossom servers.</string>
<string name="manage_stored_files_empty">No stored files found on your Blossom servers.</string>
<string name="blossom_mirror_to_missing">Mirror to missing</string>
<string name="blossom_delete_from">Delete from…</string>
<string name="blossom_delete_from_host">Delete from %1$s</string>
<string name="blossom_report">Report</string>
<string name="blossom_open">Open</string>
<string name="blossom_payment_required">This server requires payment to upload: %1$s</string>
<string name="blossom_payment_title">Payment required</string>
<string name="blossom_payment_message">%1$s charges a lightning payment to store this file. Pay from your connected wallet to continue.</string>
<string name="blossom_pay">Pay</string>
<plurals name="blossom_pay_sats">
<item quantity="one">Pay %1$d sat</item>
<item quantity="other">Pay %1$d sats</item>
</plurals>
<string name="blossom_report_title">Report blob</string>
<string name="blossom_report_comment_hint">Reason (optional)</string>
<string name="blossom_send">Send</string>
<string name="recommended_media_servers">Recommended Media Servers</string>
<string name="built_in_servers_description">Amethyst\'s default list. You can add them individually or add the list.</string>
<string name="use_default_servers">Use Default List</string>
@@ -26,31 +26,34 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nipB7Blossom.BlossomReport
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.sha256.sha256
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.nio.file.Files
/**
* `amy blossom <upload|download|list|delete>` — Blossom blob storage
* (nak's `blossom`). Uploads/lists/deletes are authed with the active
* account (BUD-01/02/04 kind:24242 events); downloads are public.
* `amy blossom <upload|media|download|list|delete|check|mirror|report>` — Blossom
* blob storage (nak's `blossom`, but fuller). Auth'd operations use the active
* account (BUD-01/02/04/05/09 kind:24242 events); downloads and HEAD checks are
* public.
*
* upload --server URL FILE [--mime-type M]
* download URL [--out FILE] (or: download HASH --server URL)
* list --server URL [USER] (USER defaults to the active account)
* media --server URL FILE [--mime-type M] (BUD-05 optimize on upload)
* download URL [--out FILE] (or: download HASH --server URL)
* list --server URL [USER] (USER defaults to the active account)
* delete HASH --server URL
* check --server URL HASH[,HASH] (BUD-01 HEAD probe)
* mirror --server URL SOURCE-URL (BUD-04)
* report --server URL HASH [--type T] [--comment C] [--uploader HEX]
*
* Thin assembly only: HTTP + auth live in commons `BlossomClient` /
* `BlossomAuth` and quartz `BlossomAuthorizationEvent`; this file wires
* flags and shapes output. List/delete use OkHttp directly (no client
* method exists) with the quartz-built auth header.
* Thin assembly only: all HTTP + auth live in commons `BlossomClient` /
* `BlossomAuth` and quartz `BlossomAuthorizationEvent` / `BlossomReport`; this
* file wires flags and shapes output. Auth tokens are scoped to `--server` so
* they can't be replayed elsewhere (BUD-11).
*/
object BlossomCommands {
suspend fun dispatch(
@@ -60,14 +63,16 @@ object BlossomCommands {
route(
"blossom",
tail,
"blossom <upload|download|list|delete|check|mirror>",
"blossom <upload|media|download|list|delete|check|mirror|report>",
mapOf(
"upload" to { rest -> upload(dataDir, rest) },
"upload" to { rest -> upload(dataDir, rest, media = false) },
"media" to { rest -> upload(dataDir, rest, media = true) },
"download" to { rest -> download(dataDir, rest) },
"list" to { rest -> list(dataDir, rest) },
"delete" to { rest -> delete(dataDir, rest) },
"check" to { rest -> check(dataDir, rest) },
"mirror" to { rest -> mirror(dataDir, rest) },
"report" to { rest -> report(dataDir, rest) },
),
)
@@ -87,22 +92,11 @@ object BlossomCommands {
// Read-only HEAD probe — no auth, so it runs anonymously without an account.
Context.openOrAnonymous(dataDir).use { _ ->
val http = OkHttpClient()
val client = BlossomClient()
val results =
hashes.map { hash ->
val req =
Request
.Builder()
.url(BlossomServerUrl.blob(server, hash))
.head()
.build()
val (found, status) =
try {
http.newCall(req).execute().use { it.isSuccessful to it.code }
} catch (e: Exception) {
false to -1
}
mapOf("sha256" to hash, "found" to found, "status" to status)
val found = client.has(hash, server)
mapOf("sha256" to hash, "found" to found)
}
val allFound = results.all { it["found"] == true }
Output.emit(mapOf("server" to server, "all_found" to allFound, "results" to results))
@@ -122,39 +116,35 @@ object BlossomCommands {
val args = Args(rest)
val server = args.flag("server") ?: return Output.error("bad_args", "blossom mirror requires --server URL")
val sourceUrl = args.positional(0, "source-url")
val hash = sourceUrl.substringAfterLast('/').substringBefore('.')
val hash =
sourceUrl
.substringBefore('?')
.substringBefore('#')
.substringAfterLast('/')
.substringBefore('.')
if (hash.length != 64 || hash.any { it !in "0123456789abcdef" }) {
return Output.error("bad_args", "could not extract a sha256 from the source url '$sourceUrl'")
}
Context.open(dataDir).use { ctx ->
ctx.prepare()
val auth = BlossomAuthorizationEvent.createUploadAuth(hash, 0, "Mirror $hash", ctx.signer).toAuthorizationHeader()
val body = """{"url":${Output.mapper.writeValueAsString(sourceUrl)}}""".toRequestBody("application/json".toMediaType())
val req =
Request
.Builder()
.url(server.removeSuffix("/") + "/mirror")
.header("Authorization", auth)
.put(body)
.build()
OkHttpClient().newCall(req).execute().use { response ->
if (!response.isSuccessful) {
return Output.error("http_error", "mirror failed: HTTP ${response.code} ${response.headers[BlossomServerUrl.REASON_HEADER] ?: ""}")
}
val node = Output.mapper.readTree(response.body.string())
val auth = BlossomAuth.createUploadAuth(hash, 0, "Mirror $hash", ctx.signer, servers = listOf(server))
return withPayment(server) {
val node = BlossomClient().mirror(sourceUrl, server, auth)
Output.emit(mapOf("server" to server, "sha256" to hash, "blob" to node))
0
}
return 0
}
}
private suspend fun upload(
dataDir: DataDir,
rest: Array<String>,
media: Boolean,
): Int {
val args = Args(rest)
val server = args.flag("server") ?: return Output.error("bad_args", "blossom upload requires --server URL")
val verb = if (media) "media" else "upload"
val server = args.flag("server") ?: return Output.error("bad_args", "blossom $verb requires --server URL")
val path = args.positional(0, "file")
val file = File(path)
if (!file.isFile) return Output.error("bad_args", "no such file: $path")
@@ -165,18 +155,27 @@ object BlossomCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val auth = BlossomAuth.createUploadAuth(hash, file.length(), "Upload ${file.name}", ctx.signer)
val result = BlossomClient().upload(file, mime, server, auth)
Output.emit(
mapOf(
"url" to result.url,
"sha256" to (result.sha256 ?: hash),
"size" to (result.size ?: file.length()),
"type" to (result.type ?: mime),
"server" to server,
),
)
return 0
val client = BlossomClient()
val auth =
if (media) {
BlossomAuth.createMediaAuth(hash, file.length(), "Optimize ${file.name}", ctx.signer, servers = listOf(server))
} else {
BlossomAuth.createUploadAuth(hash, file.length(), "Upload ${file.name}", ctx.signer, servers = listOf(server))
}
return withPayment(server) {
val result = if (media) client.media(file, mime, server, auth) else client.upload(file, mime, server, auth)
Output.emit(
mapOf(
"url" to result.url,
"sha256" to (result.sha256 ?: hash),
"ox" to result.ox,
"size" to (result.size ?: file.length()),
"type" to (result.type ?: mime),
"server" to server,
),
)
0
}
}
}
@@ -190,7 +189,7 @@ object BlossomCommands {
val url = if (server != null && !target.startsWith("http")) BlossomServerUrl.blob(server, target) else target
// Public download — no auth, so it runs anonymously without an account.
Context.openOrAnonymous(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { _ ->
val bytes =
BlossomClient().download(url)
?: return Output.error("not_found", "server returned no blob for $url")
@@ -220,21 +219,9 @@ object BlossomCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val pubkey = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
val auth = BlossomAuthorizationEvent.createListAuth(ctx.signer, "List blobs").toAuthorizationHeader()
val listUrl = server.removeSuffix("/") + "/list/" + pubkey
val request =
Request
.Builder()
.url(listUrl)
.header("Authorization", auth)
.get()
.build()
OkHttpClient().newCall(request).execute().use { response ->
if (!response.isSuccessful) return Output.error("http_error", "server returned HTTP ${response.code} for $listUrl")
val node = Output.mapper.readTree(response.body.string())
Output.emit(mapOf("server" to server, "pubkey" to pubkey, "count" to node.size(), "blobs" to node))
}
val auth = BlossomAuth.createListAuth("List blobs", ctx.signer, servers = listOf(server))
val blobs = BlossomClient().list(server, pubkey, auth)
Output.emit(mapOf("server" to server, "pubkey" to pubkey, "count" to blobs.size, "blobs" to blobs))
return 0
}
}
@@ -249,26 +236,56 @@ object BlossomCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val auth = BlossomAuthorizationEvent.createDeleteAuth(hash, "Delete blob", ctx.signer).toAuthorizationHeader()
val blobUrl = BlossomServerUrl.blob(server, hash)
val request =
Request
.Builder()
.url(blobUrl)
.header("Authorization", auth)
.delete()
.build()
OkHttpClient().newCall(request).execute().use { response ->
Output.emit(
mapOf(
"sha256" to hash,
"server" to server,
"deleted" to response.isSuccessful,
"status" to response.code,
),
)
return if (response.isSuccessful) 0 else 1
}
val auth = BlossomAuth.createDeleteAuth(hash, "Delete blob", ctx.signer, servers = listOf(server))
val deleted = BlossomClient().delete(hash, server, auth)
Output.emit(mapOf("sha256" to hash, "server" to server, "deleted" to deleted))
return if (deleted) 0 else 1
}
}
/**
* `blossom report --server URL HASH [--type T] [--comment C] [--uploader HEX]`
* — PUT a signed NIP-56 (kind 1984) blob report to the server's /report
* endpoint (BUD-09). [type] is a NIP-56 report code (spam, illegal, nudity,
* malware, …), defaulting to `other`.
*/
private suspend fun report(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val server = args.flag("server") ?: return Output.error("bad_args", "blossom report requires --server URL")
val hash = args.positional(0, "sha256")
val type =
args.flag("type")?.let { code ->
ReportType.entries.firstOrNull { it.code.equals(code, ignoreCase = true) }
?: return Output.error("bad_args", "unknown --type '$code' (use ${ReportType.entries.joinToString("|") { it.code }})")
} ?: ReportType.OTHER
val comment = args.flag("comment") ?: ""
val uploader = args.flag("uploader")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val event = ctx.signer.sign(BlossomReport.build(hash, type, uploader, comment))
val ok = BlossomClient().report(server, event.toJson())
Output.emit(mapOf("server" to server, "sha256" to hash, "type" to type.code, "reported" to ok))
return if (ok) 0 else 1
}
}
/** Runs [block], turning a BUD-07 402 into a clean payment-required error. */
private inline fun withPayment(
server: String,
block: () -> Int,
): Int =
try {
block()
} catch (e: BlossomPaymentException) {
Output.error(
"payment_required",
"server $server requires payment: ${e.payment.reason ?: "402"}" +
(e.payment.cashu?.let { " (cashu available)" } ?: "") +
(e.payment.lightning?.let { " (lightning invoice available)" } ?: ""),
)
}
}
+1
View File
@@ -5,3 +5,4 @@ nests/state/
clink/state-clink-headless/
relaygroup/state-relaygroup-headless/
sync/state-sync-deletions/
blossom/state-blossom-live/
+12
View File
@@ -49,6 +49,18 @@ A third, slimmer harness covers the NIP-17 DM surface:
is shared with the Marmot harness's checkout at
`marmot/state-headless/nostr-rs-relay/`).
A harness covers Blossom blob storage (BUD-01/02/04/09) against **live**
public servers rather than a loopback relay:
- **`blossom/blossom-live.sh`** — drives the full `amy blossom` lifecycle
(upload → HEAD check → download-and-verify-hash → list → cross-server
mirror → delete) against a real Blossom server. Server-side write
rejections (whitelists, rate limits, payment) record as SKIP, not FAIL —
only a broken client contract (bad descriptor, hash mismatch) fails.
Defaults to `https://files.sovbit.host`; pass `--mirror-server URL` to
exercise BUD-04. Example:
`blossom/blossom-live.sh --server https://files.sovbit.host --mirror-server https://blossom.primal.net`.
A fourth harness covers audio rooms (NIP-53 + moq-lite):
- **`nests/nests-interop.sh`** — fully manual interop between Amethyst
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env bash
#
# blossom-live.sh — end-to-end interop smoke test for `amy blossom`
# against a LIVE public Blossom server (BUD-01/02/04/09).
#
# Exercises the full blob lifecycle with a throwaway identity:
#
# 1. upload — PUT /upload returns a Blob Descriptor; sha256 matches
# the bytes we sent (content addressing).
# 2. check — HEAD /<sha256> reports the blob present (BUD-01).
# 3. download — GET /<sha256> returns bytes whose sha256 matches.
# 4. list — GET /list/<pubkey> includes the uploaded hash (BUD-02).
# 5. mirror — PUT /mirror on a 2nd server pulls the blob by URL and
# reports it present (BUD-04). [needs --mirror-server]
# 6. delete — DELETE /<sha256> then HEAD shows it gone (BUD-02).
#
# Third-party servers gate writes in wildly different ways (whitelists,
# rate limits, payment, content sniffing). So a server-side *rejection*
# of a write is recorded as SKIP, not FAIL — only a broken client
# contract (bad descriptor, hash mismatch, unparuseable list) FAILS.
#
# Usage:
# ./blossom-live.sh [--server URL] [--mirror-server URL] [--no-build]
#
# Defaults: --server https://files.sovbit.host (accepts anonymous
# uploads + clean deletes at time of writing). Override freely.
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-blossom-live"
LOG_DIR="$STATE_DIR/logs"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
SERVER="${BLOSSOM_SERVER:-https://files.sovbit.host}"
MIRROR_SERVER="${BLOSSOM_MIRROR_SERVER:-}"
NO_BUILD=0
while [[ $# -gt 0 ]]; do
case "$1" in
--server) SERVER="$2"; shift ;;
--mirror-server) MIRROR_SERVER="$2"; shift ;;
--no-build) NO_BUILD=1 ;;
-h|--help)
sed -n '3,27p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
exit 0 ;;
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
esac
shift
done
mkdir -p "$STATE_DIR" "$LOG_DIR"
: >"$LOG_FILE"
: >"$RESULTS_FILE"
# shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh"
# Throwaway identity under a private fake $HOME with the plaintext secret
# backend (no passphrase prompt). Never publishes to relays.
export HOME="$STATE_DIR/home"
mkdir -p "$HOME"
amy() { "$AMY_BIN" --secret-backend plaintext --account blossomtest --json "$@" 2>>"$LOG_FILE"; }
cleanup() {
local rc=$?
trap - EXIT INT TERM HUP
print_summary
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
banner "amy blossom live interop ($RUN_TS) — server=$SERVER"
if [[ ! -x "$AMY_BIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "amy binary missing at $AMY_BIN and --no-build set"
record_result preflight.amy fail "no amy binary"
exit 1
fi
step "building amy (./gradlew :cli:installDist)"
(cd "$REPO_ROOT" && ./gradlew :cli:installDist -q) >>"$LOG_FILE" 2>&1 \
|| { fail_msg "amy build failed"; record_result preflight.build fail ""; exit 1; }
fi
# A tiny 1x1 PNG — Blossom servers are media-oriented and often reject
# arbitrary octet-streams, so use a real recognizable image.
BLOB="$STATE_DIR/px.png"
printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x00\x03\x00\x01\xf5\xd7\xd4\xc2\x00\x00\x00\x00IEND\xaeB\x60\x82' >"$BLOB"
step "creating throwaway identity"
amy create --name "Blossom Live Test" >>"$LOG_FILE" 2>&1 || { skip_msg "could not create identity"; record_result preflight.identity skip ""; exit 0; }
PUBKEY=$(amy whoami | jq -r '.hex // empty')
# ---------------------------------------------------------------------------
# 1. upload
# ---------------------------------------------------------------------------
banner "T1 — upload"
UP=$(amy blossom upload --server "$SERVER" --mime-type image/png "$BLOB")
UP_RC=$?
HASH=$(printf '%s' "$UP" | jq -r '.sha256 // empty')
URL=$(printf '%s' "$UP" | jq -r '.url // empty')
if [[ "$UP_RC" -ne 0 || -z "$HASH" ]]; then
skip_msg "server rejected the upload (policy/whitelist/rate limit) — $(printf '%s' "$UP" | jq -r '.detail // .' 2>/dev/null)"
record_result T1.upload skip "server-side write rejection"
exit 0
fi
if [[ ${#HASH} -eq 64 && -n "$URL" ]]; then
record_result T1.upload pass "sha256=$HASH"
else
record_result T1.upload fail "descriptor missing sha256/url: $UP"
fi
# ---------------------------------------------------------------------------
# 2. check (HEAD)
# ---------------------------------------------------------------------------
banner "T2 — HEAD check reports present"
CK=$(amy blossom check --server "$SERVER" "$HASH")
if [[ "$(printf '%s' "$CK" | jq -r '.all_found')" == "true" ]]; then
record_result T2.check pass "HEAD found the blob"
else
record_result T2.check fail "HEAD did not find the just-uploaded blob: $CK"
fi
# ---------------------------------------------------------------------------
# 3. download — bytes hash back to the same sha256
# ---------------------------------------------------------------------------
banner "T3 — download round-trips the content hash"
DL=$(amy blossom download "$HASH" --server "$SERVER")
DL_HASH=$(printf '%s' "$DL" | jq -r '.sha256 // empty')
if [[ "$DL_HASH" == "$HASH" ]]; then
record_result T3.download pass "downloaded bytes hash to $DL_HASH"
else
record_result T3.download fail "download hash $DL_HASH != upload hash $HASH"
fi
# ---------------------------------------------------------------------------
# 4. list — our hash appears in the pubkey's blob list
# ---------------------------------------------------------------------------
banner "T4 — list includes the uploaded blob"
LS=$(amy blossom list --server "$SERVER")
if printf '%s' "$LS" | jq -e --arg h "$HASH" '.blobs[]? | select(.sha256 == $h)' >/dev/null 2>&1; then
record_result T4.list pass "blob present in /list/$PUBKEY"
elif [[ "$(printf '%s' "$LS" | jq -r '.error // empty')" != "" ]]; then
skip_msg "server does not implement /list"
record_result T4.list skip "no /list endpoint"
else
record_result T4.list fail "uploaded hash not in list: $(printf '%s' "$LS" | head -c 200)"
fi
# ---------------------------------------------------------------------------
# 5. mirror (optional 2nd server)
# ---------------------------------------------------------------------------
if [[ -n "$MIRROR_SERVER" ]]; then
banner "T5 — mirror $SERVER -> $MIRROR_SERVER"
MR=$(amy blossom mirror --server "$MIRROR_SERVER" "$URL")
if [[ $? -eq 0 && "$(printf '%s' "$MR" | jq -r '.sha256 // empty')" == "$HASH" ]]; then
CK2=$(amy blossom check --server "$MIRROR_SERVER" "$HASH")
if [[ "$(printf '%s' "$CK2" | jq -r '.all_found')" == "true" ]]; then
record_result T5.mirror pass "mirrored blob present on $MIRROR_SERVER"
else
record_result T5.mirror fail "mirror reported ok but HEAD misses it"
fi
else
skip_msg "mirror target rejected the write (policy) — $(printf '%s' "$MR" | jq -r '.detail // .' 2>/dev/null | head -c 160)"
record_result T5.mirror skip "server-side mirror rejection"
fi
else
skip_msg "no --mirror-server given; skipping BUD-04 mirror"
record_result T5.mirror skip "not configured"
fi
# ---------------------------------------------------------------------------
# 6. delete then confirm gone
# ---------------------------------------------------------------------------
banner "T6 — delete removes the blob"
DEL=$(amy blossom delete --server "$SERVER" "$HASH")
DEL_OK=$(printf '%s' "$DEL" | jq -r '.deleted // false')
CK3=$(amy blossom check --server "$SERVER" "$HASH")
GONE=$([[ "$(printf '%s' "$CK3" | jq -r '.all_found')" == "false" ]] && echo true || echo false)
if [[ "$GONE" == "true" ]]; then
record_result T6.delete pass "blob no longer found after delete (deleted flag=$DEL_OK)"
else
skip_msg "server did not honor delete (may forbid deletes) — deleted=$DEL_OK"
record_result T6.delete skip "delete not honored"
fi
@@ -30,7 +30,29 @@ object BlossomAuth {
size: Long,
alt: String,
signer: NostrSigner,
): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer).toAuthorizationHeader()
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, servers).toAuthorizationHeader()
suspend fun createMediaAuth(
hash: HexKey,
size: Long,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createMediaAuth(hash, size, alt, signer, servers).toAuthorizationHeader()
suspend fun createListAuth(
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createListAuth(signer, alt, servers).toAuthorizationHeader()
suspend fun createDeleteAuth(
hash: HexKey,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, servers).toAuthorizationHeader()
fun encodeAuthHeader(event: BlossomAuthorizationEvent): String = event.toAuthorizationHeader()
}
@@ -0,0 +1,343 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.upload
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.BufferedSink
import okio.source
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
/**
* Thrown when a Blossom server answers with `402 Payment Required` (BUD-07). The
* caller pays [payment] (Cashu or Lightning) and retries the request with the
* proof attached.
*/
class BlossomPaymentException(
val server: String,
val payment: BlossomPaymentRequired,
) : RuntimeException("Payment required by $server: ${payment.reason ?: "402 Payment Required"}")
/** Result of a BUD-06 `HEAD /upload` or `HEAD /media` preflight. */
data class BlossomPreflightResult(
val accepted: Boolean,
val status: Int,
val reason: String? = null,
)
/**
* Blossom HTTP client for JVM consumers (desktop + CLI + Android's
* shared logic). Owns no global state — pass a configured [OkHttpClient] (e.g.
* desktop's Tor-aware `DesktopHttpClient.currentClient()`) for proxying /
* connection pooling. The default constructor uses a fresh OkHttpClient — fine
* for one-shot uses such as the CLI.
*
* Covers BUD-01 (download), BUD-02 (upload/list/delete), BUD-04 (mirror),
* BUD-05 (media), BUD-06 (preflight), BUD-07 (402), and BUD-09 (report). Every
* optional endpoint degrades gracefully so callers can fan out across servers of
* varying capability.
*/
open class BlossomClient(
private val okHttpClient: OkHttpClient = OkHttpClient(),
) {
open suspend fun upload(
file: File,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = putBlob(BlossomServerUrl.upload(serverBaseUrl), fileBody(file, contentType), serverBaseUrl, authHeader)
/**
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
*/
open suspend fun upload(
bytes: ByteArray,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = putBlob(BlossomServerUrl.upload(serverBaseUrl), bytes.toRequestBody(contentType.toMediaType()), serverBaseUrl, authHeader)
/**
* BUD-05 media-optimization upload: `PUT /media`. The server MAY transform the
* blob, so the returned descriptor's `sha256` is the *optimized* hash and `ox`
* the original. Requires a `t=media` auth token.
*/
open suspend fun media(
file: File,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = putBlob(BlossomServerUrl.media(serverBaseUrl), fileBody(file, contentType), serverBaseUrl, authHeader)
open suspend fun media(
bytes: ByteArray,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = putBlob(BlossomServerUrl.media(serverBaseUrl), bytes.toRequestBody(contentType.toMediaType()), serverBaseUrl, authHeader)
/**
* BUD-04 mirror: ask [serverBaseUrl] to fetch and store the blob already at
* [sourceUrl]. The server verifies the downloaded bytes hash to the `x` tag in
* the (upload) auth token. Returns the mirrored blob's descriptor.
*/
open suspend fun mirror(
sourceUrl: String,
serverBaseUrl: String,
authHeader: String?,
paymentProof: BlossomPaymentProof? = null,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val body = JsonMapper.toJson(MirrorRequest(sourceUrl)).toRequestBody("application/json".toMediaType())
val request =
Request
.Builder()
.url(BlossomServerUrl.mirror(serverBaseUrl))
.apply {
authHeader?.let { addHeader("Authorization", it) }
paymentProof?.headers()?.forEach { (name, value) -> addHeader(name, value) }
}.put(body)
.build()
okHttpClient.newCall(request).execute().use { parseDescriptor(it, serverBaseUrl) }
}
/**
* BUD-02 list: `GET /list/<pubkey>`. Returns the pubkey's blob descriptors on
* this server (may be empty; servers MAY not implement it). [authHeader] is a
* `t=list` token — some servers require it, others allow anonymous listing.
*/
open suspend fun list(
serverBaseUrl: String,
pubkey: HexKey,
authHeader: String?,
): List<BlossomUploadResult> =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(BlossomServerUrl.list(serverBaseUrl, pubkey))
.apply { authHeader?.let { addHeader("Authorization", it) } }
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
check402(response, serverBaseUrl)
if (!response.isSuccessful) {
val reason = response.headers[BlossomServerUrl.REASON_HEADER] ?: response.code.toString()
throw RuntimeException("List failed ($serverBaseUrl): $reason")
}
val body = response.body.string().ifBlank { "[]" }
JsonMapper.fromJson<List<BlossomUploadResult>>(body)
}
}
/**
* BUD-02 delete: `DELETE /<sha256>[.ext]`. [authHeader] is a `t=delete` token
* scoped to the hash (and ideally to this server). Returns true on 2xx.
*/
open suspend fun delete(
hash: HexKey,
serverBaseUrl: String,
authHeader: String?,
extension: String = "",
): Boolean =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(BlossomServerUrl.blob(serverBaseUrl, hash, extension))
.apply { authHeader?.let { addHeader("Authorization", it) } }
.delete()
.build()
okHttpClient.newCall(request).execute().use { it.isSuccessful }
}
/**
* BUD-01 HEAD probe: does [serverBaseUrl] hold [hash]? A cheap "which server
* has which blob" check that needs no auth on most servers.
*/
open suspend fun has(
hash: HexKey,
serverBaseUrl: String,
): Boolean =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(BlossomServerUrl.blob(serverBaseUrl, hash))
.head()
.build()
try {
okHttpClient.newCall(request).execute().use { it.isSuccessful }
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
false
}
}
/**
* BUD-06 preflight: `HEAD /upload` (or `/media` when [media] is true). A 200
* means the server would accept the blob; any other status carries an optional
* `X-Reason`. Per spec this is only a hint — never gate an upload hard on it.
*/
open suspend fun preflight(
hash: HexKey,
size: Long,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
media: Boolean = false,
): BlossomPreflightResult =
withContext(Dispatchers.IO) {
val endpoint = if (media) BlossomServerUrl.media(serverBaseUrl) else BlossomServerUrl.upload(serverBaseUrl)
val request =
Request
.Builder()
.url(endpoint)
.head()
.addHeader(BlossomServerUrl.X_SHA_256_HEADER, hash)
.addHeader(BlossomServerUrl.X_CONTENT_LENGTH_HEADER, size.toString())
.addHeader(BlossomServerUrl.X_CONTENT_TYPE_HEADER, contentType)
.apply { authHeader?.let { addHeader("Authorization", it) } }
.build()
okHttpClient.newCall(request).execute().use { response ->
BlossomPreflightResult(
accepted = response.isSuccessful,
status = response.code,
reason = response.headers[BlossomServerUrl.REASON_HEADER],
)
}
}
/**
* BUD-09 report: `PUT /report` with a signed NIP-56 (kind 1984) report event as
* the JSON body. Returns true on 2xx.
*/
open suspend fun report(
serverBaseUrl: String,
reportEventJson: String,
): Boolean =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(BlossomServerUrl.report(serverBaseUrl))
.put(reportEventJson.toRequestBody("application/json".toMediaType()))
.build()
okHttpClient.newCall(request).execute().use { it.isSuccessful }
}
/**
* Download a blob from an absolute URL — typically a Blossom GET endpoint
* `<server>/<sha256>`. Returns the raw bytes, or `null` when the server
* responds with a non-2xx status. Connection-level failures (DNS, refused,
* timeout) propagate as [java.io.IOException] so the caller can try the next
* server.
*
* This does NOT verify the blob's hash — content-addressed verification is
* the caller's responsibility (see quartz `StaticSiteResolver.verify`), since
* a Blossom server is untrusted and may return a substituted blob.
*/
open suspend fun download(url: String): ByteArray? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) response.body.bytes() else null
}
}
private fun fileBody(
file: File,
contentType: String,
): RequestBody =
object : RequestBody() {
override fun contentType() = contentType.toMediaType()
override fun contentLength() = file.length()
override fun writeTo(sink: BufferedSink) {
file.inputStream().source().use(sink::writeAll)
}
}
private suspend fun putBlob(
endpoint: String,
body: RequestBody,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(endpoint)
.apply { authHeader?.let { addHeader("Authorization", it) } }
.put(body)
.build()
okHttpClient.newCall(request).execute().use { parseDescriptor(it, serverBaseUrl) }
}
private fun parseDescriptor(
response: Response,
serverBaseUrl: String,
): BlossomUploadResult {
check402(response, serverBaseUrl)
if (!response.isSuccessful) {
val reason = response.headers[BlossomServerUrl.REASON_HEADER] ?: response.code.toString()
throw RuntimeException("Request failed ($serverBaseUrl): $reason")
}
val body = response.body.string().ifBlank { throw RuntimeException("$serverBaseUrl returned no body") }
return JsonMapper.fromJson<BlossomUploadResult>(body)
}
/** Surfaces a BUD-07 `402 Payment Required` as a typed exception the caller can act on. */
private fun check402(
response: Response,
serverBaseUrl: String,
) {
if (response.code == 402) {
throw BlossomPaymentException(serverBaseUrl, BlossomPaymentRequired.fromHeaders { response.headers[it] })
}
}
@kotlinx.serialization.Serializable
private data class MirrorRequest(
val url: String,
)
}
@@ -1,140 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.upload
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import java.io.File
/**
* Blossom HTTP client for JVM consumers (desktop + CLI). Owns no global
* state — pass a configured [OkHttpClient] (e.g. desktop's Tor-aware
* `DesktopHttpClient.currentClient()`) for proxying / connection pooling.
* The default constructor uses a fresh OkHttpClient — fine for one-shot
* uses such as the CLI.
*/
open class BlossomClient(
private val okHttpClient: OkHttpClient = OkHttpClient(),
) {
open suspend fun upload(
file: File,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val apiUrl = BlossomServerUrl.upload(serverBaseUrl)
val requestBody =
object : RequestBody() {
override fun contentType() = contentType.toMediaType()
override fun contentLength() = file.length()
override fun writeTo(sink: BufferedSink) {
file.inputStream().source().use(sink::writeAll)
}
}
val requestBuilder =
Request
.Builder()
.url(apiUrl)
.put(requestBody)
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use {
if (!it.isSuccessful) {
val reason = it.headers[BlossomServerUrl.REASON_HEADER] ?: it.code.toString()
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
}
val body = it.body.string().ifBlank { throw RuntimeException("Upload to $serverBaseUrl returned no body") }
JsonMapper.fromJson<BlossomUploadResult>(body)
}
}
/**
* Download a blob from an absolute URL — typically a Blossom GET endpoint
* `<server>/<sha256>`. Returns the raw bytes, or `null` when the server
* responds with a non-2xx status. Connection-level failures (DNS, refused,
* timeout) propagate as [java.io.IOException] so the caller can try the next
* server.
*
* This does NOT verify the blob's hash — content-addressed verification is
* the caller's responsibility (see quartz `StaticSiteResolver.verify`), since
* a Blossom server is untrusted and may return a substituted blob.
*/
open suspend fun download(url: String): ByteArray? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) response.body.bytes() else null
}
}
/**
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
*/
open suspend fun upload(
bytes: ByteArray,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val apiUrl = BlossomServerUrl.upload(serverBaseUrl)
val requestBody = bytes.toRequestBody(contentType.toMediaType())
val requestBuilder =
Request
.Builder()
.url(apiUrl)
.put(requestBody)
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use {
if (!it.isSuccessful) {
val reason = it.headers[BlossomServerUrl.REASON_HEADER] ?: it.code.toString()
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
}
val body = it.body.string().ifBlank { throw RuntimeException("Upload to $serverBaseUrl returned no body") }
JsonMapper.fromJson<BlossomUploadResult>(body)
}
}
}
@@ -57,29 +57,47 @@ class BlossomAuthorizationEvent(
hash: HexKey,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
) = createAuth("get", hash, null, alt, signer, createdAt)
) = createAuth("get", hash, null, alt, signer, servers, createdAt)
suspend fun createListAuth(
signer: NostrSigner,
alt: String,
servers: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
) = createAuth("list", null, null, alt, signer, createdAt)
) = createAuth("list", null, null, alt, signer, servers, createdAt)
suspend fun createDeleteAuth(
hash: HexKey,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
) = createAuth("delete", hash, null, alt, signer, createdAt)
) = createAuth("delete", hash, null, alt, signer, servers, createdAt)
suspend fun createUploadAuth(
hash: HexKey,
size: Long,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
) = createAuth("upload", hash, size, alt, signer, createdAt)
) = createAuth("upload", hash, size, alt, signer, servers, createdAt)
/**
* BUD-05 media-optimization auth (`t=media`). The [hash] is the sha256 of
* the *original* bytes the client sends to `PUT /media`; the server returns
* a descriptor whose hash is the optimized blob's.
*/
suspend fun createMediaAuth(
hash: HexKey,
size: Long,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
) = createAuth("media", hash, size, alt, signer, servers, createdAt)
private suspend fun createAuth(
type: String,
@@ -87,15 +105,26 @@ class BlossomAuthorizationEvent(
fileSize: Long?,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
): BlossomAuthorizationEvent {
// BUD-11 `server` tags scope the token to specific domains so an upload
// or delete token can't be replayed against another server. The value
// MUST be the lowercase bare domain (no scheme/port/path).
val serverTags =
servers
.map { BlossomServerUrl.domain(it) }
.filter { it.isNotBlank() }
.distinct()
.map { arrayOf("server", it) }
val tags =
listOfNotNull(
arrayOf("t", type),
arrayOf("expiration", TimeUtils.oneHourAhead().toString()),
fileSize?.let { arrayOf("size", it.toString()) },
hash?.let { arrayOf("x", it) },
)
) + serverTags
return signer.sign(createdAt, KIND, tags.toTypedArray(), alt)
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipB7Blossom
/**
* BUD-07 payment challenge parsed from a `402 Payment Required` response. A paid
* Blossom server answers upload/mirror/media requests with a 402 and one or both
* of the payment headers; the client pays, then retries the same request with the
* proof attached.
*
* - [cashu] — a NUT-24 Cashu token request string from the `X-Cashu` header. The
* client pays it (e.g. via a NIP-60 wallet) and retries with the settled token.
* - [lightning] — a BOLT-11 invoice string from the `X-Lightning` header. The
* client pays it and retries; the preimage is the proof.
* - [reason] — the optional human-readable `X-Reason` message.
*
* Kept transport-agnostic (no OkHttp) so it can live in `commonMain`: build it
* from any per-name header lookup via [fromHeaders].
*/
data class BlossomPaymentRequired(
val cashu: String? = null,
val lightning: String? = null,
val reason: String? = null,
) {
/** True when the server offered at least one payment method we could attempt. */
fun hasPaymentOption(): Boolean = !cashu.isNullOrBlank() || !lightning.isNullOrBlank()
companion object {
/**
* Reads the BUD-07 headers from a 402 response. [header] returns the value
* for a header name (case-insensitive at the transport layer), or null.
*/
inline fun fromHeaders(header: (String) -> String?): BlossomPaymentRequired =
BlossomPaymentRequired(
cashu = header(BlossomServerUrl.X_CASHU_HEADER)?.trim('"', ' ')?.ifBlank { null },
lightning = header(BlossomServerUrl.X_LIGHTNING_HEADER)?.trim('"', ' ')?.ifBlank { null },
reason = header(BlossomServerUrl.REASON_HEADER)?.ifBlank { null },
)
}
}
/**
* The proof a client sends when retrying a request after settling a BUD-07 [BlossomPaymentRequired]:
* a settled Cashu token (echoed in `X-Cashu`) or the preimage of the paid BOLT-11 invoice
* (echoed in `X-Lightning`).
*/
data class BlossomPaymentProof(
val cashu: String? = null,
val lightningPreimage: String? = null,
) {
/** The header name/value pairs to attach to the retried request. */
fun headers(): List<Pair<String, String>> =
buildList {
cashu?.let { add(BlossomServerUrl.X_CASHU_HEADER to it) }
lightningPreimage?.let { add(BlossomServerUrl.X_LIGHTNING_HEADER to it) }
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipB7Blossom
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip56Reports.hash
import com.vitorpamplona.quartz.nip56Reports.user
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* BUD-09 blob report: a NIP-56 (kind 1984) report event scoped to a blob by its
* sha256 (`x` tag) rather than to a nostr event. The signed event is PUT to a
* server's `/report` endpoint so the operator can review problematic content.
*
* Reuses the shared NIP-56 tag builders ([hash], [user]) so a blob report is a
* regular [ReportEvent] — clients that already parse kind 1984 pick up the
* reported hash via `HashSha256Tag`.
*/
object BlossomReport {
fun build(
blobHash: HexKey,
type: ReportType,
uploader: HexKey? = null,
comment: String = "",
createdAt: Long = TimeUtils.now(),
) = eventTemplate(ReportEvent.KIND, comment, createdAt) {
hash(blobHash, type)
uploader?.let { user(it, type) }
}
}
@@ -21,9 +21,10 @@
package com.vitorpamplona.quartz.nipB7Blossom
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Rfc3986
/**
* Endpoint helpers for the Blossom HTTP API (BUD-01 / BUD-02). Centralizes the
* Endpoint helpers for the Blossom HTTP API (BUD-01 … BUD-12). Centralizes the
* protocol's URL shapes and well-known header names so every transport — the
* commons JVM `BlossomClient`, the Android uploader, the CLI — builds them the
* same way instead of re-deriving `<server>/upload` and `X-Reason` by hand.
@@ -32,15 +33,51 @@ object BlossomServerUrl {
/** BUD-01 upload endpoint path: `PUT <server>/upload`. */
const val UPLOAD_PATH = "/upload"
/** BUD-04 mirror endpoint path: `PUT <server>/mirror`. */
const val MIRROR_PATH = "/mirror"
/** BUD-05 media-optimization endpoint path: `PUT <server>/media`. */
const val MEDIA_PATH = "/media"
/** BUD-02 list endpoint path prefix: `GET <server>/list/<pubkey>`. */
const val LIST_PATH = "/list/"
/** BUD-09 report endpoint path: `PUT <server>/report`. */
const val REPORT_PATH = "/report"
/**
* Header a Blossom server SHOULD set with a human-readable failure reason on
* a non-2xx response (BUD-01).
*/
const val REASON_HEADER = "X-Reason"
/** BUD-06 preflight request headers for `HEAD /upload` and `HEAD /media`. */
const val X_SHA_256_HEADER = "X-SHA-256"
const val X_CONTENT_TYPE_HEADER = "X-Content-Type"
const val X_CONTENT_LENGTH_HEADER = "X-Content-Length"
/** BUD-07 payment headers carried on a `402 Payment Required` response. */
const val X_CASHU_HEADER = "X-Cashu"
const val X_LIGHTNING_HEADER = "X-Lightning"
/** `<server>/upload`, collapsing any trailing slash on [serverBaseUrl]. */
fun upload(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + UPLOAD_PATH
/** BUD-04 `<server>/mirror`, collapsing any trailing slash on [serverBaseUrl]. */
fun mirror(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + MIRROR_PATH
/** BUD-05 `<server>/media`, collapsing any trailing slash on [serverBaseUrl]. */
fun media(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + MEDIA_PATH
/** BUD-02 `<server>/list/<pubkey>`, collapsing any trailing slash on [serverBaseUrl]. */
fun list(
serverBaseUrl: String,
pubkey: HexKey,
): String = serverBaseUrl.removeSuffix("/") + LIST_PATH + pubkey
/** BUD-09 `<server>/report`, collapsing any trailing slash on [serverBaseUrl]. */
fun report(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + REPORT_PATH
/**
* BUD-01 blob endpoint `<server>/<sha256>[.<ext>]`, used for GET and DELETE.
* A blank [extension] omits the suffix.
@@ -53,4 +90,20 @@ object BlossomServerUrl {
val suffix = if (extension.isBlank()) "" else ".$extension"
return serverBaseUrl.removeSuffix("/") + "/" + hash + suffix
}
/**
* The lowercase bare domain of [serverBaseUrl], as required by the BUD-11
* `server` authorization tag ("lowercase domain name only", no scheme/port).
* Falls back to a best-effort strip when the URL can't be parsed.
*/
fun domain(serverBaseUrl: String): String =
try {
Rfc3986.host(serverBaseUrl).substringBefore(":").lowercase()
} catch (_: Exception) {
serverBaseUrl
.substringAfter("://")
.substringBefore("/")
.substringBefore(":")
.lowercase()
}
}
@@ -35,10 +35,18 @@ data class BlossomUploadResult(
val type: String? = null,
// upload time
val uploaded: Long? = null,
// (BUD-05) The sha256 hash of the *original* blob, before server-side
// optimization. Only returned by the `/media` endpoint when the server
// transforms the file (so sha256 != ox).
val ox: HexKey? = null,
// magnet link
val magnet: String? = null,
// info hash
val infohash: String? = null,
// ipfs link
val ipfs: String? = null,
// (BUD-08) Optional NIP-94 file-metadata tags describing this blob, so a
// client gets standardized metadata (dimensions, blurhash, alt, …) without
// a separate request. Each entry is a NIP-94 tag: [name, value, …].
val nip94: List<List<String>>? = null,
)
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipB7Blossom
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.io.encoding.Base64
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class BlossomAuthorizationEventTest {
private val signer = NostrSignerInternal(KeyPair())
private val hash = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
@Test
fun uploadAuthHasRequiredTags() =
runTest {
val event = BlossomAuthorizationEvent.createUploadAuth(hash, 184292, "Uploading cat.png", signer)
assertEquals(BlossomAuthorizationEvent.KIND, event.kind)
assertEquals("upload", event.tags.first { it[0] == "t" }[1])
assertEquals(hash, event.tags.first { it[0] == "x" }[1])
assertEquals("184292", event.tags.first { it[0] == "size" }[1])
// NIP-40 expiration must be in the future.
val expiration = event.tags.first { it[0] == "expiration" }[1].toLong()
assertTrue(expiration > event.createdAt)
}
@Test
fun mediaAuthUsesMediaVerb() =
runTest {
val event = BlossomAuthorizationEvent.createMediaAuth(hash, 100, "Optimizing", signer)
assertEquals("media", event.tags.first { it[0] == "t" }[1])
}
@Test
fun serverScopeEmitsLowercaseBareDomainTags() =
runTest {
val event =
BlossomAuthorizationEvent.createDeleteAuth(
hash,
"Delete blob",
signer,
servers = listOf("https://CDN.Example.com/", "https://blossom.band:443/upload"),
)
val serverTags = event.tags.filter { it[0] == "server" }.map { it[1] }
assertEquals(listOf("cdn.example.com", "blossom.band"), serverTags)
}
@Test
fun deduplicatesServerScopeByDomain() =
runTest {
val event =
BlossomAuthorizationEvent.createUploadAuth(
hash,
1,
"Upload",
signer,
servers = listOf("https://cdn.example.com/a", "https://cdn.example.com/b"),
)
assertEquals(1, event.tags.count { it[0] == "server" })
}
@Test
fun noServerScopeWhenListEmpty() =
runTest {
val event = BlossomAuthorizationEvent.createUploadAuth(hash, 1, "Upload", signer)
assertTrue(event.tags.none { it[0] == "server" })
}
@Test
fun authorizationHeaderIsNostrPrefixedBase64OfTheEvent() =
runTest {
val event = BlossomAuthorizationEvent.createListAuth(signer, "List blobs")
val header = event.toAuthorizationHeader()
assertTrue(header.startsWith(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME))
val decoded = Base64.decode(header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)).decodeToString()
assertEquals(event.toJson(), decoded)
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipB7Blossom
import kotlin.test.Test
import kotlin.test.assertEquals
class BlossomServerUrlTest {
private val sha256 = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
private val pubkey = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e0"
@Test
fun buildsEndpointPaths() {
assertEquals("https://cdn.example.com/upload", BlossomServerUrl.upload("https://cdn.example.com"))
assertEquals("https://cdn.example.com/mirror", BlossomServerUrl.mirror("https://cdn.example.com"))
assertEquals("https://cdn.example.com/media", BlossomServerUrl.media("https://cdn.example.com"))
assertEquals("https://cdn.example.com/report", BlossomServerUrl.report("https://cdn.example.com"))
assertEquals("https://cdn.example.com/list/$pubkey", BlossomServerUrl.list("https://cdn.example.com", pubkey))
}
@Test
fun collapsesTrailingSlash() {
assertEquals("https://cdn.example.com/upload", BlossomServerUrl.upload("https://cdn.example.com/"))
assertEquals("https://cdn.example.com/mirror", BlossomServerUrl.mirror("https://cdn.example.com/"))
assertEquals("https://cdn.example.com/list/$pubkey", BlossomServerUrl.list("https://cdn.example.com/", pubkey))
}
@Test
fun buildsBlobUrlWithOptionalExtension() {
assertEquals("https://cdn.example.com/$sha256", BlossomServerUrl.blob("https://cdn.example.com", sha256))
assertEquals("https://cdn.example.com/$sha256.png", BlossomServerUrl.blob("https://cdn.example.com", sha256, "png"))
}
@Test
fun extractsLowercaseBareDomainForServerScope() {
assertEquals("cdn.example.com", BlossomServerUrl.domain("https://cdn.example.com"))
assertEquals("cdn.example.com", BlossomServerUrl.domain("https://CDN.Example.com/"))
assertEquals("cdn.example.com", BlossomServerUrl.domain("https://cdn.example.com:8443/upload"))
assertEquals("blossom.band", BlossomServerUrl.domain("https://blossom.band"))
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipB7Blossom
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class BlossomUploadResultTest {
@Test
fun parsesMinimalDescriptor() {
val json =
"""
{
"url": "https://cdn.example.com/b167.png",
"sha256": "b167",
"size": 184292,
"type": "image/png",
"uploaded": 1725105921
}
""".trimIndent()
val result = JsonMapper.fromJson<BlossomUploadResult>(json)
assertEquals("https://cdn.example.com/b167.png", result.url)
assertEquals("b167", result.sha256)
assertEquals(184292, result.size)
assertEquals("image/png", result.type)
assertNull(result.ox)
assertNull(result.nip94)
}
@Test
fun parsesMediaDescriptorWithOriginalHashAndNip94() {
// BUD-05 /media returns the optimized blob's hash in `sha256` and the
// original in `ox`; BUD-08 adds the `nip94` tag array.
val json =
"""
{
"url": "https://cdn.example.com/opt.png",
"sha256": "optimizedhash",
"ox": "originalhash",
"size": 123,
"type": "image/png",
"uploaded": 1725105921,
"nip94": [
["url", "https://cdn.example.com/opt.png"],
["m", "image/png"],
["x", "optimizedhash"],
["size", "123"]
]
}
""".trimIndent()
val result = JsonMapper.fromJson<BlossomUploadResult>(json)
assertEquals("optimizedhash", result.sha256)
assertEquals("originalhash", result.ox)
assertEquals(4, result.nip94?.size)
assertEquals(listOf("m", "image/png"), result.nip94?.get(1))
}
@Test
fun ignoresUnknownFields() {
val json = """{"url":"https://x/y","sha256":"a","serverSpecific":{"foo":1},"extra":"z"}"""
val result = JsonMapper.fromJson<BlossomUploadResult>(json)
assertEquals("a", result.sha256)
}
@Test
fun readsBud07PaymentHeaders() {
val headers =
mapOf(
BlossomServerUrl.X_CASHU_HEADER to "\"cashuBToken...\"",
BlossomServerUrl.X_LIGHTNING_HEADER to "lnbc10n1...",
BlossomServerUrl.REASON_HEADER to "Payment required: 10 sats",
)
val payment = BlossomPaymentRequired.fromHeaders { headers[it] }
assertEquals("cashuBToken...", payment.cashu)
assertEquals("lnbc10n1...", payment.lightning)
assertEquals("Payment required: 10 sats", payment.reason)
assertEquals(true, payment.hasPaymentOption())
}
@Test
fun paymentWithNoMethodsIsNotPayable() {
val payment = BlossomPaymentRequired.fromHeaders { null }
assertEquals(false, payment.hasPaymentOption())
}
@Test
fun paymentProofBuildsRetryHeaders() {
assertEquals(
listOf(BlossomServerUrl.X_LIGHTNING_HEADER to "preimageabc"),
BlossomPaymentProof(lightningPreimage = "preimageabc").headers(),
)
assertEquals(
listOf(BlossomServerUrl.X_CASHU_HEADER to "cashuToken"),
BlossomPaymentProof(cashu = "cashuToken").headers(),
)
assertEquals(emptyList(), BlossomPaymentProof().headers())
}
}