mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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()
|
||||
|
||||
+16
-2
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+298
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-207
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+86
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+155
@@ -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
|
||||
}
|
||||
}
|
||||
+80
@@ -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)
|
||||
}
|
||||
}
|
||||
+84
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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)
|
||||
|
||||
|
||||
+101
@@ -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(
|
||||
|
||||
+1
@@ -92,6 +92,7 @@ fun MediaServersScaffold(
|
||||
AllMediaBody(
|
||||
blossomServersViewModel = blossomServersViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
+501
@@ -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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
+427
@@ -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
|
||||
}
|
||||
}
|
||||
+139
@@ -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)
|
||||
|
||||
+10
@@ -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()
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
@@ -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)" } ?: ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ nests/state/
|
||||
clink/state-clink-headless/
|
||||
relaygroup/state-relaygroup-headless/
|
||||
sync/state-sync-deletions/
|
||||
blossom/state-blossom-live/
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+196
@@ -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
|
||||
+23
-1
@@ -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()
|
||||
}
|
||||
+343
@@ -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,
|
||||
)
|
||||
}
|
||||
-140
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-5
@@ -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)
|
||||
}
|
||||
|
||||
+75
@@ -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) }
|
||||
}
|
||||
}
|
||||
+54
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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,
|
||||
)
|
||||
|
||||
+102
@@ -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)
|
||||
}
|
||||
}
|
||||
+59
@@ -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"))
|
||||
}
|
||||
}
|
||||
+121
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user