diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 05fb30ef20..6a197f7c1a 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -567,6 +567,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.secp256k1.kmp.jni.jvm) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.junit) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 439097b41a..05d2a66b4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageFileScreen import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen +import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsQrScreen import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -583,6 +584,7 @@ fun BuildNavigation( composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ShareNoteAsImageScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) } + composableFromEndArgs { ShareNoteAsQrScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ContactListUsersScreen(it.noteId, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index d6f6780592..e63bb36ad4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -507,6 +507,10 @@ sealed class Route { val id: String, ) : Route() + @Serializable data class ShareNoteAsQr( + val id: String, + ) : Route() + @Serializable data class ContactListUsers( val noteId: String, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt index 15693b2e7e..bc1dac8728 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt @@ -38,8 +38,9 @@ import com.vitorpamplona.amethyst.ui.stringRes * both from the reaction-row Share button and from the note's 3-dot menu). * * Only the true "send it somewhere" options live here — browser link, image - * file, image URL. The copy-to-clipboard options stay in the 3-dot menu, so - * they are intentionally NOT part of this shared element. + * file, image URL, and the display-only QR code. The copy-to-clipboard + * options stay in the 3-dot menu, so they are intentionally NOT part of this + * shared element. * * Callers only render these for non-private notes: every option exposes the * note publicly (a shareable web link, or an image of it), which must never @@ -76,4 +77,8 @@ fun ShareActionRows( nav.nav(Route.ShareNoteAsImage(shareId)) onDismiss() } + M3ActionRow(icon = MaterialSymbols.QrCode2, text = stringRes(R.string.share_as_qr)) { + nav.nav(Route.ShareNoteAsQr(shareId)) + onDismiss() + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/QrPayload.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/QrPayload.kt new file mode 100644 index 0000000000..e07753189c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/QrPayload.kt @@ -0,0 +1,54 @@ +/* + * 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.note.share + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.ui.note.externalLinkForNote + +/** Which of the two payloads the QR code currently encodes. */ +enum class QrPayloadMode { + /** + * An `https://njump.to/…` link. The default, because a stock phone camera will offer to + * open an http(s) URL but may treat a bare custom scheme as inert text. + */ + Web, + + /** + * A `nostr:nevent1…` / `nostr:naddr1…` URI. Resolves without a web round-trip and is what + * in-app scanners expect. + */ + Nostr, +} + +/** + * The string encoded into the QR code for [note] in [mode]. + * + * Both payloads carry the note's relay hint: [externalLinkForNote] builds its URL from + * `toNAddr()` / `toNEvent()`, which already call `relayHintUrl()`. + */ +fun qrPayloadFor( + note: Note, + mode: QrPayloadMode, +): String = + when (mode) { + QrPayloadMode.Web -> externalLinkForNote(note) + QrPayloadMode.Nostr -> note.toNostrUri() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt new file mode 100644 index 0000000000..5bbf7bfdfa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt @@ -0,0 +1,258 @@ +/* + * 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.note.share + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.getActivityWindow +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.stringRes + +// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a +// fixed pixel count subtracted from raw size.width, so its share of the tile grows as density +// falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable +// resolution on low-density screens. Deriving the size from the available column width keeps +// enough real pixels per module; this only bounds it from growing unreasonably large on tablets. +private val QrMaxSize = 320.dp + +/** + * Display-only screen presenting [id]'s note as a scannable QR code. + * + * There is no export or save action by design — the screen exists to be held up and + * photographed by another device. + * + * F6: the Scaffold (and its back button) lives in this id-based wrapper, OUTSIDE LoadNote's + * null check, so an id that never resolves to a note still leaves the user a way back — only + * the body inside is empty in that case. Rendering nothing else for an unresolved id is + * deliberate, matching ShareNoteAsImageScreen; only the missing chrome was the bug. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareNoteAsQrScreen( + id: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) }, + ) { pad -> + LoadNote(id, accountViewModel) { note -> + if (note != null) { + ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareNoteAsQrScreen( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.share_as_qr), nav) }, + ) { pad -> + ShareNoteAsQrScreenContent(note, accountViewModel, nav, pad) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShareNoteAsQrScreenContent( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, + pad: PaddingValues, +) { + var mode by remember { mutableStateOf(QrPayloadMode.Web) } + + // F4: keyed on the observed note state, not on `note` alone (a stable object identity that + // does not change while the event and the author's relay list are still loading). A payload + // computed before then would omit the relay hint (Note.relayHintUrl()) and never recompute. + // Keying on `noteState` re-derives the payload once the event arrives — the same observation + // SharedNoteCard uses for its own sensitivity gate (F1). + val noteState by observeNote(note, accountViewModel) + val payload = remember(noteState, mode) { qrPayloadFor(note, mode) } + + KeepScreenBrightAndAwake() + + // F5: scrollable so the toggle and hint — the screen's only controls — stay reachable on a + // short viewport (landscape, split screen, large font scale) where the square QR plus the + // card above it can otherwise exceed the available height. A plain fillMaxSize() Column would + // silently place that overflow outside its bounds instead of clipping or scrolling to it. + Column( + modifier = + Modifier + .padding(pad) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SharedNoteCard(note, accountViewModel, nav) + + val qrContentDescription = + when (mode) { + QrPayloadMode.Web -> stringRes(R.string.share_as_qr_code_description_web) + QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_code_description_nostr) + } + QrCodeDrawer( + contents = payload, + modifier = + Modifier + .widthIn(max = QrMaxSize) + .fillMaxWidth() + .semantics { contentDescription = qrContentDescription }, + ) + + // Fill the width so each button gets an equal, generous half (a bare + // SingleChoiceSegmentedButtonRow shrinks to content and clips longer labels), and pass an + // empty `icon` so the selected-state checkmark never steals horizontal room from the + // label — selection is already signalled by the fill colour. Both matter for translated + // labels, which are often longer than the English "Web link" / "Nostr link". + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + val modes = listOf(QrPayloadMode.Web, QrPayloadMode.Nostr) + modes.forEachIndexed { index, candidate -> + SegmentedButton( + selected = mode == candidate, + onClick = { mode = candidate }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = modes.size), + icon = {}, + ) { + Text( + text = + when (candidate) { + QrPayloadMode.Web -> stringRes(R.string.share_as_qr_mode_web) + QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_mode_nostr) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + + Text( + text = + when (mode) { + QrPayloadMode.Web -> stringRes(R.string.share_as_qr_hint_web) + QrPayloadMode.Nostr -> stringRes(R.string.share_as_qr_hint_nostr) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +/** + * Raises the screen to full brightness and prevents it sleeping while the QR is displayed, + * restoring both on exit. + * + * This is functional, not polish: the screen exists to be photographed, and a dark-theme phone + * on auto-brightness in a dim room is exactly the case that fails. + * + * Residual hazard, not reachable today: [LocalView.current] is the Activity's single shared root + * `ComposeView`, not a view scoped to this screen. During a nav transition two compositions can + * briefly coexist on that same root view, so this screen's `onDispose` could in theory clobber + * brightness/`keepScreenOn` state an incoming screen has already set. Nothing in the current nav + * graph triggers that overlap, so this is left as a comment rather than code. + */ +@Composable +private fun KeepScreenBrightAndAwake() { + val view = LocalView.current + // NOT `(view.context as? Activity)`: under Compose the context is routinely a + // ContextThemeWrapper, so that cast silently yields null and brightness never changes — + // no crash, no log, just a dead feature. getActivityWindow() unwraps the ContextWrapper + // chain (WindowUtils.kt:39-46). + val window = getActivityWindow() + + DisposableEffect(window, view) { + // Capture the RAW attribute, not a computed fraction. When no override is set this is + // BRIGHTNESS_OVERRIDE_NONE (-1f), and restoring that value returns the device to auto + // brightness. Restoring a *computed* fraction would install an override where none + // existed and silently disable auto-brightness for the rest of the session. + val previousBrightness = window?.attributes?.screenBrightness + + // F8: same capture/replay discipline as brightness above, and for the same reason. + // `view` is the Activity's single shared root ComposeView, and PlayerEventListener + // (ControlWhenPlayerIsActive.kt:150-165) owns this exact flag while media plays. + // Hard-setting `false` on dispose — instead of restoring what was here before this + // screen took it over — would clobber that ownership: navigating back from the QR + // screen while audio or video is still playing would let the screen sleep mid-playback. + val previousKeepScreenOn = view.keepScreenOn + + window?.let { + it.attributes = it.attributes.apply { screenBrightness = 1f } + } + view.keepScreenOn = true + + onDispose { + // Restore the captured value rather than calling a release helper: resetting to + // BRIGHTNESS_OVERRIDE_NONE unconditionally would clobber an override the user + // already had, e.g. one left by the fullscreen video controls. + window?.let { w -> + previousBrightness?.let { prev -> + w.attributes = w.attributes.apply { screenBrightness = prev } + } + } + view.keepScreenOn = previousKeepScreenOn + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCard.kt new file mode 100644 index 0000000000..85a62034c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCard.kt @@ -0,0 +1,320 @@ +/* + * 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.note.share + +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.Row +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.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +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.commons.model.Note +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.ui.components.collectContentWarningReasons +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.GalleryThumbnail +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.UrlImageView +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW +import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoEvent +import com.vitorpamplona.quartz.nip71Video.alt +import com.vitorpamplona.quartz.nip71Video.blurhash +import com.vitorpamplona.quartz.nip71Video.mimeType +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.imetas + +private val CardHeight = 72.dp +private val ThumbSize = 56.dp +private val ThumbShape = RoundedCornerShape(9.dp) + +/** + * A fixed-height preview of the note being shared, shown above the QR code. + * + * The height is fixed on purpose: it keeps the QR code in the same screen position for every + * note, so the screen is predictable to hold up to a scanner. That is why this does not use + * [com.vitorpamplona.amethyst.ui.note.NoteCompose] — see the design spec for the full reasoning, + * but in short, `isQuotedNote` never reaches the media renderer and note images render at their + * natural aspect ratio with no height ceiling. + */ +@Composable +fun SharedNoteCard( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth().height(CardHeight).padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Plain SensitivityWarning is NOT enough here: it gates on event.isSensitiveOrNSFW(), + // which only reads the note-level content-warning tag / nsfw hashtag. GalleryThumbnail's + // inner gate (GalleryThumb.kt:236) reads each media entry's per-imeta `contentWarning`, + // but none of GalleryThumb.kt's four media-construction sites ever set that field, so it + // is always null and that inner gate is permanently dead on this path. A note whose only + // warning lives inside an `imeta` tag (e.g. a kind 20 PictureEvent) would then render + // completely unblurred. `hasImetaContentWarning` below checks every imeta for the mere + // PRESENCE of a `content-warning` key, not for non-blank reason text: an imeta warning + // with an empty reason string (`["content-warning"]` with no second element) still counts + // — collectContentWarningReasons()'s `takeIf { it.isNotBlank() }` would silently drop that + // exact case, which is what let it slip the gate before. collectContentWarningReasons() + // is still called for the human-readable *reason text*, shown in the covered box's + // accessibility label when one exists — it never drives the show/hide decision. + // + // `note.event` is a plain field read that never recomposes if this composable is + // rendered before the note's event has arrived over the relay (id-only reference, e.g. + // straight off a deep link). observeNote() subscribes both the relay finder and the + // LocalCache flow, so `event` below updates — and this whole gate recomputes — the + // moment the event loads or changes, the same idiom GalleryThumbnail itself already uses + // (GalleryThumb.kt:78). + // + // This deliberately does NOT use the shared ContentWarningGate: its overlay + // (ContentWarningOverlayBody, SensitivityWarning.kt:254+) opens with an 80.dp icon box + // that consumes this card's entire 56.dp thumbnail height, pushing the title, reasons, + // and "Show anyway" button below the clipped, tappable area. Reshaping that shared + // composable was rejected — six other screens depend on its current layout — so this + // call site renders its own compact, permanently-covered state instead: no reveal + // affordance, because this screen is held up in public and pointed at someone else's + // camera. `accountViewModel.showSensitiveContent()` is still honoured exactly as + // ContentWarningGate honours it (SensitivityWarning.kt:138-140), so a user who has + // opted into seeing sensitive content globally sees the real thumbnail here too. Either + // way the thumbnail box stays a fixed 56.dp, keeping this Row's height fixed at 72.dp. + // + // `nav` is passed because GalleryThumbnail's signature demands it, but it is unused + // there (GalleryThumb.kt:76) — navigation comes from ClickableNote at its other call + // site. This card is not tappable. + val noteState by observeNote(note, accountViewModel) + val event = noteState.note.event + val reasons = remember(event) { event?.let { collectContentWarningReasons(it) } ?: emptySet() } + val hasImetaContentWarning = + remember(event) { + event?.imetas()?.any { it.properties.containsKey(ContentWarningTag.TAG_NAME) } ?: false + } + val isSensitive = + remember(event, hasImetaContentWarning) { + event != null && (event.isSensitiveOrNSFW() || hasImetaContentWarning) + } + val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle() + val isGated = isSensitive && showSensitiveContent != true + + // Thumbnail source, in priority order: + // 1. structured media event (kind 20/21/22, gallery, live clip) -> GalleryThumbnail; + // 2. an image carried in an `imeta` tag of an otherwise-unstructured note (a kind 1 + // image post — content is typically just the media URL) -> render that image; + // 3. no media at all -> the author's round AVATAR, which (unlike GalleryThumbnail's own + // DisplayGalleryAuthorBanner fallback, a banner crop) cannot be mistaken for the + // note's own picture (F7). + // hasStructuredMedia() mirrors GalleryThumbnail's own per-kind checks (GalleryThumb.kt: + // 82-186); contentImage covers the case GalleryThumbnail does NOT handle — a bare image + // URL in an imeta on a kind 1 — so those posts show the picture instead of avatar + URL. + val hasStructuredMedia = remember(event) { event != null && hasStructuredMedia(event) } + val contentImage = remember(event) { event?.let { firstContentImage(it) } } + + Box(Modifier.size(ThumbSize).clip(ThumbShape)) { + if (isGated) { + Box( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.Warning, + contentDescription = + reasons.firstOrNull()?.let { stringRes(R.string.content_warning_with_reason, it) } + ?: stringRes(R.string.share_as_qr_thumbnail_hidden_sensitive), + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else if (hasStructuredMedia) { + GalleryThumbnail(note, accountViewModel, nav) + } else if (contentImage != null) { + // UrlImageView crops to fill (ContentScale.Crop) and honours the account's + // show-images setting and blossom bridge on its own; the enclosing 56.dp Box + // bounds it so the card height stays fixed. No extra SensitivityWarning: the + // isGated branch above already covered the sensitive case. + UrlImageView(contentImage, accountViewModel) + } else { + NoteAuthorPicture(note, ThumbSize, accountViewModel) + } + } + + Column(Modifier.weight(1f)) { + Text( + text = note.author?.toBestDisplayName() ?: "", + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = secondaryLineFor(event, isGated, contentImage != null), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** + * Whether [event] carries the kind of structured media [GalleryThumbnail] would render (a + * profile-gallery entry, kind 20 picture, kind 21/22 video, or live-activity clip with a video + * URL) — used only to pick between the media thumbnail and the author-avatar fallback (F7). + * Mirrors GalleryThumbnail's own when-branch conditions (GalleryThumb.kt:82-186) rather than + * duplicating its full [com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent] construction. + */ +private fun hasStructuredMedia(event: Event): Boolean = + when (event) { + is ProfileGalleryEntryEvent -> event.urls().isNotEmpty() + is PictureEvent -> event.imetaTags().isNotEmpty() + is VideoEvent -> event.imetaTags().isNotEmpty() + is LiveActivitiesClipEvent -> event.videoUrl() != null + else -> false + } + +/** True when this `imeta` describes an image — by declared mime type, or failing that its URL. */ +internal fun IMetaTag.isImage(): Boolean { + val mime = mimeType()?.firstOrNull() + return if (mime != null) mime.startsWith("image/") else RichTextParser.isImageUrl(url) +} + +/** + * The first image carried in an [event]'s `imeta` tags, as a renderable [MediaUrlImage], or null + * if the note has none. Covers the common kind-1 image post whose content is just a media URL — + * a case [GalleryThumbnail] does not handle (its when-branches fall through to the author banner). + * Only images are returned; a video-only imeta yields null and the card falls back to the avatar + * rather than feeding a video URL to the image loader. + */ +internal fun firstContentImage(event: Event): MediaUrlImage? { + val imeta = event.imetas().firstOrNull { it.isImage() } ?: return null + return MediaUrlImage( + url = imeta.url, + description = imeta.alt()?.firstOrNull(), + blurhash = imeta.blurhash()?.firstOrNull(), + mimeType = imeta.mimeType()?.firstOrNull(), + ) +} + +/** + * The one-or-two-line description under the author name: an article's title, else the note's own + * text (with any embedded media URLs stripped), else the image's alt text, else a kind label. + * + * F3: when [isGated] (the same sensitive-and-not-opted-in decision computed for the thumbnail, + * passed in rather than recomputed) title, content and alt text are all skipped in favor of the + * neutral kind label — otherwise this line would render the sensitive note's own text unblurred + * right next to the covered thumbnail, which is exactly what `Text.kt`'s `SensitivityWarning` + * wrapping exists to prevent for note bodies elsewhere in the app. The author name stays visible + * either way; only this line is affected. + * + * [hasContentImage] lets an image-only post whose text and alt are both empty fall back to a + * "Picture" label rather than a blank line. + */ +@Composable +private fun secondaryLineFor( + event: Event?, + isGated: Boolean, + hasContentImage: Boolean, +): String { + if (event == null) return "" + + // F10: remembered against (event, isGated) so a long article body is not re-trimmed on every + // recomposition — only when the underlying event or the gate decision actually changes. + val bodyText = remember(event, isGated) { secondaryBodyTextFor(event, isGated) } + if (bodyText != null) return bodyText + + return when { + event is PictureEvent -> stringRes(R.string.share_as_qr_kind_picture) + event is VideoEvent -> stringRes(R.string.kind_video) + event is LongTextNoteEvent -> stringRes(R.string.article) + hasContentImage -> stringRes(R.string.share_as_qr_kind_picture) + else -> "" + } +} + +// Plain (non-@Composable) so it can be wrapped in `remember` — `stringRes` calls, which the +// kind-label fallback needs, are not allowed inside a remember calculation lambda. +internal fun secondaryBodyTextFor( + event: Event, + isGated: Boolean, +): String? { + // Gated: never surface the note's own title, content or alt text, only the kind label above. + if (isGated) return null + + if (event is LongTextNoteEvent) { + val title = event.title() + if (!title.isNullOrBlank()) return title + } + + // An image-only post's content is typically just the media URL(s). Strip any that appear + // verbatim (only when the note actually has imeta media, so a plain article — no imeta — is + // never scanned) so the line does not show a bare CDN URL. If nothing meaningful remains, + // fall through to the alt text, then the kind label. + val mediaUrls = event.imetas().map { it.url } + val stripped = + if (mediaUrls.isEmpty()) { + event.content + } else { + mediaUrls.fold(event.content) { acc, url -> acc.replace(url, "") } + } + // F10: bounded prefix — this line only ever shows two lines of bodySmall text, so there is + // no need to trim() a full long-form article body (potentially tens of KB) to get there. + val content = stripped.take(MAX_SECONDARY_LINE_CHARS).trim() + if (content.isNotEmpty()) return content + + return event + .imetas() + .firstOrNull { it.isImage() } + ?.alt() + ?.firstOrNull() + ?.takeIf { it.isNotBlank() } +} + +private const val MAX_SECONDARY_LINE_CHARS = 280 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f37c43ae9e..f5e5780d39 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -531,6 +531,15 @@ Share as Image Url Generating preview… Shared via Amethyst + Share as QR + Web link + Nostr link + Scan with any phone camera + Scan with a Nostr app + Picture + QR code containing a web link to this note + QR code containing a Nostr link to this note + Thumbnail hidden for sensitive content Author ID Note ID Copy Text diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/QrPayloadTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/QrPayloadTest.kt new file mode 100644 index 0000000000..72a8c43723 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/QrPayloadTest.kt @@ -0,0 +1,97 @@ +/* + * 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.note.share + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class QrPayloadTest { + private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray())) + + private suspend fun textNote(): Note { + val event = TextNoteEvent.build("hello qr") {}.let { aliceSigner.sign(it) } + val note = Note(event.id) + note.event = event + return note + } + + @Test + fun webMode_returnsAnHttpsNjumpLink() = + runTest { + val payload = qrPayloadFor(textNote(), QrPayloadMode.Web) + + assertTrue( + "web mode must produce an https URL so a stock phone camera can action it, got: $payload", + payload.startsWith("https://"), + ) + assertTrue(payload.contains("nevent1")) + } + + @Test + fun nostrMode_returnsANostrUriWithNevent() = + runTest { + val payload = qrPayloadFor(textNote(), QrPayloadMode.Nostr) + + assertTrue(payload.startsWith("nostr:nevent1")) + } + + @Test + fun bothModes_encodeTheSameNote() = + runTest { + val note = textNote() + val web = qrPayloadFor(note, QrPayloadMode.Web) + val nostr = qrPayloadFor(note, QrPayloadMode.Nostr) + + // The bech32 body must be identical; only the wrapper differs. + assertEquals(nostr.removePrefix("nostr:"), web.substringAfterLast('/')) + } + + @Test + fun addressableNote_nostrMode_yieldsNaddrNotNevent() = + runTest { + // build(description, title, summary, image, publishedAt, dTag, createdAt, init) + // — `title` is a required positional; `dTag` must be named. + // LongTextNoteEvent.kt:148-157. + val event = + LongTextNoteEvent + .build("body", "My Article", dTag = "my-article") {} + .let { aliceSigner.sign(it) } + val note = AddressableNote(event.address()) + note.event = event + + val payload = qrPayloadFor(note, QrPayloadMode.Nostr) + + assertTrue( + "AddressableNote must encode as naddr via the toNEvent() override, got: $payload", + payload.startsWith("nostr:naddr1"), + ) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCardTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCardTest.kt new file mode 100644 index 0000000000..0e70df1ace --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/share/SharedNoteCardTest.kt @@ -0,0 +1,160 @@ +/* + * 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.note.share + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip92IMeta.imetas +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SharedNoteCardTest { + private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray())) + + private val blossomUrl = "https://blossom.primal.net/66332885d206714439e39e441b6539622a07c108" + + private suspend fun kind1( + content: String, + imetaUrl: String? = null, + mime: String? = null, + alt: String? = null, + ): Event = + TextNoteEvent + .build(content) { + if (imetaUrl != null) { + imetas( + listOf( + IMetaTagBuilder(imetaUrl) + .apply { + mime?.let { add("m", it) } + alt?.let { add("alt", it) } + }.build(), + ), + ) + } + }.let { aliceSigner.sign(it) } + + // ---- firstContentImage ---- + + @Test + fun imageImeta_isPickedAsTheThumbnailImage() = + runTest { + val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg") + + val image = firstContentImage(note) + + assertEquals(blossomUrl, image?.url) + } + + @Test + fun plainTextNote_hasNoContentImage() = + runTest { + val note = kind1(content = "just some words, no media") + + assertNull(firstContentImage(note)) + } + + @Test + fun videoImeta_isNotRenderedAsAnImage() = + runTest { + // A video-only imeta must not be fed to the image loader; the card falls back to the avatar. + val note = kind1(content = "clip", imetaUrl = "https://cdn.example/v", mime = "video/mp4") + + assertNull(firstContentImage(note)) + } + + // ---- secondaryBodyTextFor ---- + + @Test + fun imageOnlyPost_stripsTheBareUrlToNothing() = + runTest { + // content is exactly the media URL — the whole point of the fix: never show the raw CDN URL. + val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg") + + assertNull(secondaryBodyTextFor(note, isGated = false)) + } + + @Test + fun imageOnlyPostWithAlt_fallsBackToAltText() = + runTest { + val note = kind1(content = blossomUrl, imetaUrl = blossomUrl, mime = "image/jpeg", alt = "A sunset") + + assertEquals("A sunset", secondaryBodyTextFor(note, isGated = false)) + } + + @Test + fun textPlusImage_keepsTheTextWithoutTheUrl() = + runTest { + val note = kind1(content = "check this out $blossomUrl", imetaUrl = blossomUrl, mime = "image/jpeg") + + assertEquals("check this out", secondaryBodyTextFor(note, isGated = false)) + } + + @Test + fun gatedNote_yieldsNoBodyText() = + runTest { + val note = kind1(content = "something sensitive", imetaUrl = blossomUrl, mime = "image/jpeg", alt = "nsfw") + + assertNull(secondaryBodyTextFor(note, isGated = true)) + } + + @Test + fun article_prefersItsTitle() = + runTest { + val note = + LongTextNoteEvent + .build("the body", "My Article", dTag = "a1") {} + .let { aliceSigner.sign(it) } + + assertEquals("My Article", secondaryBodyTextFor(note, isGated = false)) + } + + // ---- IMetaTag.isImage ---- + + @Test + fun isImage_trueForImageMime_falseForVideoMime() = + runTest { + val img = IMetaTagBuilder("https://x/y").add("m", "image/png").build() + val vid = IMetaTagBuilder("https://x/y").add("m", "video/mp4").build() + + assertTrue(img.isImage()) + assertTrue(!vid.isImage()) + } + + @Test + fun isImage_fallsBackToUrlExtensionWhenNoMime() = + runTest { + val withExt = IMetaTagBuilder("https://x/photo.jpg").build() + val extensionless = IMetaTagBuilder("https://blossom.example/deadbeef").build() + + assertTrue(withExt.isImage()) + assertTrue(!extensionless.isImage()) + } +}