fix: address audit findings on the media share targets

- Correct the KDoc on the Shorts and Longs composers. They claimed
  everything posted from them lands in that feed; VideoPostKind only
  governs videos, and the gallery picker takes images with no mime
  filter, so a picked JPEG still posts as a kind-20 picture that
  neither feed reads. Say so instead of asserting a false invariant.

- Forward the shared text as the composer's caption. The media targets
  dropped EXTRA_TEXT entirely, so sharing a photo with a caption lost
  it — while the DM target kept it. The routes now carry the message
  and NewMediaModel.load() seeds the caption field from it.

- Accept SEND_MULTIPLE on the three media targets. Sharing several
  files at once previously did not offer Amethyst at all, even though
  the picture composer publishes N images as one kind-20 event. Routes
  carry a URI list; other targets take the first.

- Replace, rather than stack, a feed entry when a second share arrives
  while the first is still open. Only entries that carry attachments
  are replaced, so a feed reached from the bottom bar keeps its
  tab-root marker underneath.

- Rename NewImageButton to NewVideoFeedButton: it is the Video feed's
  composer and handles pictures and video alike.

Adds ShareTargetManifestTest, which pins the activity-alias names in
AndroidManifest.xml to the constants ShareIntentRouting matches them
by — in both directions, plus the SEND_MULTIPLE filters. That link is
invisible to the compiler and fails silently at runtime by routing a
share to the wrong composer. Verified the guard bites by renaming an
alias in the manifest alone: three of its four tests go red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4WsYWaNMPD34SBc6Ej6hx
This commit is contained in:
Claude
2026-07-30 16:07:52 +00:00
parent ea2472468b
commit dcf2807e75
14 changed files with 330 additions and 80 deletions
+18
View File
@@ -310,6 +310,12 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_picture">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity-alias>
<!-- "New Short" share target: a video shared here always becomes a NIP-71 kind-22 short so
@@ -327,6 +333,12 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_short_video">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- "New Video" share target: opens the Video feed's media composer, which publishes a
@@ -344,6 +356,12 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter android:label="@string/share_target_as_video">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
</activity-alias>
<!-- "New Highlight" share target: a browser (or reader) shares a selected passage of
@@ -81,8 +81,9 @@ open class NewMediaModel : ViewModel() {
account: Account,
uris: ImmutableList<SelectedMedia>,
videoKind: VideoPostKind = VideoPostKind.AUTO,
caption: String = "",
) {
this.caption = ""
this.caption = caption
this.account = account
this.multiOrchestrator = MultiOrchestrator(uris)
this.selectedServer = defaultServer()
@@ -81,14 +81,15 @@ fun NewMediaView(
accountViewModel: AccountViewModel,
nav: INav,
videoKind: VideoPostKind = VideoPostKind.AUTO,
initialCaption: String = "",
) {
val account = accountViewModel.account
val context = LocalContext.current
val scrollState = rememberScrollState()
LaunchedEffect(uris, videoKind) {
postViewModel.load(account, uris, videoKind)
LaunchedEffect(uris, videoKind, initialCaption) {
postViewModel.load(account, uris, videoKind, initialCaption)
}
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
@@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.ui.actions.uploads
import android.content.Context
import androidx.core.net.toUri
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -40,3 +43,20 @@ suspend fun resolveSharedMedia(
SelectedMedia(uri, context.contentResolver.getType(uri))
}
}
/**
* Batch form for SEND_MULTIPLE shares: resolves every URI in one trip to the IO dispatcher
* instead of one context switch per file. Blank entries are dropped, so an empty list in means
* an empty list out and the composer stays closed.
*/
suspend fun resolveSharedMedia(
context: Context,
uriStrings: List<String>,
): ImmutableList<SelectedMedia> {
val uris = uriStrings.mapNotNull { it.ifBlank { null }?.toUri() }
if (uris.isEmpty()) return persistentListOf()
return withContext(Dispatchers.IO) {
uris.map { SelectedMedia(it, context.contentResolver.getType(it)) }.toImmutableList()
}
}
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.TabReselectCoordinato
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav
import com.vitorpamplona.amethyst.ui.navigation.routes.MediaFeedRoute
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments
import com.vitorpamplona.amethyst.ui.navigation.routes.isBaseRoute
@@ -428,7 +429,7 @@ fun BuildNavigation(
) {
composableCapped<Route.Home> { HomeScreen(accountViewModel, nav) }
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composableArgs<Route.Video> { VideoScreen(accountViewModel, nav, it.attachment) }
composableArgs<Route.Video> { VideoScreen(accountViewModel, nav, it.attachments, it.message) }
composableArgs<Route.Discover> { DiscoverScreen(it.initialTab, accountViewModel, nav) }
composableArgs<Route.Notification> { NotificationScreen(it.scrollToEventId, accountViewModel, nav) }
composableFromEnd<Route.Polls> { PollsScreen(accountViewModel, nav) }
@@ -439,7 +440,7 @@ fun BuildNavigation(
composableFromEnd<Route.ProfileBadges> { ProfileBadgesScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileAppRecommendations> { ProfileAppRecommendationsScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.AwardBadge> { AwardBadgeScreen(it.kind, it.pubKeyHex, it.dTag, accountViewModel, nav) }
composableFromEndArgs<Route.Pictures> { PicturesScreen(accountViewModel, nav, it.attachment) }
composableFromEndArgs<Route.Pictures> { PicturesScreen(accountViewModel, nav, it.attachments, it.message) }
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
composableFromEnd<Route.GitRepositories> { GitRepositoriesScreen(accountViewModel, nav) }
@@ -469,7 +470,7 @@ fun BuildNavigation(
}
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) }
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Shorts> { ShortsScreen(accountViewModel, nav, it.attachment) }
composableFromEndArgs<Route.Shorts> { ShortsScreen(accountViewModel, nav, it.attachments, it.message) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayGroups> { RelayGroupDiscoveryScreen(accountViewModel, nav) }
composableFromEndArgs<Route.BuzzDmList> { BuzzDmListScreen(it.relayUrl, accountViewModel, nav) }
@@ -971,6 +972,42 @@ fun BuildNavigation(
}
}
/** True for both share flavors: a single file/text (SEND) and a multi-file selection (SEND_MULTIPLE). */
private fun Intent.isShareAction(): Boolean = action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
/** The text Android sent with the share — a caption, a URL, or the whole payload of a text-only share. */
private fun Intent.sharedText(): String? = getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
/**
* Every content URI the share carries, in the order the sending app listed them. SEND_MULTIPLE
* puts them in a parcelable ArrayList; plain SEND has at most one. Only the media targets declare
* SEND_MULTIPLE, so every other caller can just take the first.
*/
private fun Intent.sharedStreamUris(): List<String> =
if (action == Intent.ACTION_SEND_MULTIPLE) {
IntentCompat
.getParcelableArrayListExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
?.map { it.toString() }
.orEmpty()
} else {
listOfNotNull(IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)?.toString())
}
/**
* Opens a media feed on a share. Sharing again while an earlier share is still on screen replaces
* it instead of stacking a second copy of the feed — but only when the entry on top is itself a
* share (it carries attachments). A feed the user opened from the bottom bar carries none, so it
* stays put underneath and keeps its tab-root marker.
*/
private inline fun <reified T> Nav.navToSharedFeed(route: T) where T : Route, T : MediaFeedRoute {
val current = getRouteWithArguments(T::class, controller)
if (current is MediaFeedRoute && current.attachments.isNotEmpty()) {
popUpTo(route, T::class)
} else {
newStack(route)
}
}
@Composable
private fun NavigateIfIntentRequested(
nav: Nav,
@@ -987,7 +1024,7 @@ private fun NavigateIfIntentRequested(
val activity = LocalContext.current.getActivity()
if (activity.intent.action == Intent.ACTION_SEND) {
if (activity.intent.isShareAction()) {
val target = ShareIntentRouting.targetOf(activity.intent.component?.className)
// avoids restarting the destination screen when the intent is for the screen.
@@ -1002,19 +1039,8 @@ private fun NavigateIfIntentRequested(
}
// saves the intent to avoid processing again
val message by remember {
mutableStateOf(
activity.intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
it.ifBlank { null }
},
)
}
val media by remember {
mutableStateOf(
IntentCompat.getParcelableExtra(activity.intent, Intent.EXTRA_STREAM, Uri::class.java),
)
}
val message = remember { activity.intent.sharedText() }
val attachments = remember { activity.intent.sharedStreamUris() }
when (target) {
ShareTarget.HIGHLIGHT -> {
@@ -1028,11 +1054,13 @@ private fun NavigateIfIntentRequested(
),
)
}
ShareTarget.DIRECT_MESSAGE -> nav.newStack(Route.ShareToDM(message = message, attachment = media?.toString()))
ShareTarget.PICTURE -> nav.newStack(Route.Pictures(attachment = media?.toString()))
ShareTarget.SHORT_VIDEO -> nav.newStack(Route.Shorts(attachment = media?.toString()))
ShareTarget.VIDEO -> nav.newStack(Route.Video(attachment = media?.toString()))
ShareTarget.NEW_POST -> nav.newStack(Route.NewShortNote(message = message, attachment = media?.toString()))
// The single-file composers take the first URI: only the media targets declare
// SEND_MULTIPLE, so anything else can only be carrying one.
ShareTarget.DIRECT_MESSAGE -> nav.newStack(Route.ShareToDM(message = message, attachment = attachments.firstOrNull()))
ShareTarget.PICTURE -> nav.navToSharedFeed(Route.Pictures(attachments = attachments, message = message))
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
ShareTarget.NEW_POST -> nav.newStack(Route.NewShortNote(message = message, attachment = attachments.firstOrNull()))
}
// Consume the launch intent so a later recomposition can't re-fire
@@ -1099,11 +1127,11 @@ private fun NavigateIfIntentRequested(
DisposableEffect(nav, activity) {
val consumer =
Consumer<Intent> { intent ->
if (intent.action == Intent.ACTION_SEND) {
if (intent.isShareAction()) {
val target = ShareIntentRouting.targetOf(intent.component?.className)
val message = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
val attachment =
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.toString()
val message = intent.sharedText()
val attachments = intent.sharedStreamUris()
val attachment = attachments.firstOrNull()
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions.
@@ -1129,9 +1157,9 @@ private fun NavigateIfIntentRequested(
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
}
ShareTarget.PICTURE -> nav.newStack(Route.Pictures(attachment = attachment))
ShareTarget.SHORT_VIDEO -> nav.newStack(Route.Shorts(attachment = attachment))
ShareTarget.VIDEO -> nav.newStack(Route.Video(attachment = attachment))
ShareTarget.PICTURE -> nav.navToSharedFeed(Route.Pictures(attachments = attachments, message = message))
ShareTarget.SHORT_VIDEO -> nav.navToSharedFeed(Route.Shorts(attachments = attachments, message = message))
ShareTarget.VIDEO -> nav.navToSharedFeed(Route.Video(attachments = attachments, message = message))
ShareTarget.NEW_POST ->
if (!isBaseRoute<Route.NewShortNote>(nav.controller) && (message != null || attachment != null)) {
@@ -30,18 +30,32 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.serialization.Serializable
import kotlin.reflect.KClass
/**
* A feed whose composer can be pre-loaded from an Android share. [attachments] holds the shared
* content URIs and [message] the text sent alongside them; both are empty/null for every other way
* of reaching the feed, which is how the share flow tells its own entries apart from an ordinary
* bottom-bar visit.
*/
sealed interface MediaFeedRoute {
val attachments: List<String>
val message: String?
}
sealed class Route {
@Serializable object Home : Route()
@Serializable object Message : Route()
/**
* The mixed media feed. [attachment] is set by the "New Video" share target and pre-loads the
* media composer with the shared content URI.
* The mixed media feed. [attachments] and [message] are set by the "New Video" share target: the content
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
* composer and its caption. Empty for every other way of reaching the feed.
*/
@Serializable data class Video(
val attachment: String? = null,
) : Route()
override val attachments: List<String> = emptyList(),
override val message: String? = null,
) : Route(),
MediaFeedRoute
@Serializable data class Discover(
val initialTab: DiscoverTab? = null,
@@ -88,12 +102,15 @@ sealed class Route {
}
/**
* The picture feed. [attachment] is set by the "New Picture" share target and pre-loads the
* media composer with the shared content URI.
* The picture feed. [attachments] and [message] are set by the "New Picture" share target: the content
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
* composer and its caption. Empty for every other way of reaching the feed.
*/
@Serializable data class Pictures(
val attachment: String? = null,
) : Route()
override val attachments: List<String> = emptyList(),
override val message: String? = null,
) : Route(),
MediaFeedRoute
@Serializable object Workouts : Route()
@@ -188,12 +205,15 @@ sealed class Route {
@Serializable object Products : Route()
/**
* The short-video feed. [attachment] is set by the "New Short" share target and pre-loads the
* media composer with the shared content URI.
* The short-video feed. [attachments] and [message] are set by the "New Short" share target: the content
* URIs of the shared files and the text Android sent alongside them, which pre-load the media
* composer and its caption. Empty for every other way of reaching the feed.
*/
@Serializable data class Shorts(
val attachment: String? = null,
) : Route()
override val attachments: List<String> = emptyList(),
override val message: String? = null,
) : Route(),
MediaFeedRoute
@Serializable object PublicChats : Route()
@@ -65,8 +65,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Everything posted from here is a NIP-71 kind 21 video, whether it was recorded, picked or shared,
* so it always lands in the Longs feed the composer was opened from portrait footage included.
* The Longs feed's composer. Every *video* posted from here is a NIP-71 kind-21 video recorded or
* picked alike so portrait footage lands in the Longs feed too instead of being routed to kind 22
* by its orientation. Images are unaffected: the gallery picker accepts them and they still post as
* NIP-68 kind-20 pictures, which the Longs feed does not read.
*/
@Composable
fun NewLongVideoButton(
@@ -67,16 +67,22 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* @param sharedAttachment content URI of a picture handed over by the "New Picture" share target.
* When present the composer opens on it right away, so the share lands as a NIP-68 picture post
* in this feed instead of a text note.
* The picture feed's composer. Images post as NIP-68 kind-20 pictures and land in this feed; a
* video picked from the gallery still posts as a NIP-71 video and shows up in the video feeds
* instead, since the file's mime type not the host feed decides picture vs. video.
*
* @param sharedAttachments content URIs handed over by the "New Picture" share target. When
* non-empty the composer opens on them right away, so the share lands as a picture post instead
* of a text note with a link.
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
*/
@Composable
fun NewPictureButton(
accountViewModel: AccountViewModel,
nav: INav,
navScrollToTop: () -> Unit,
sharedAttachment: String? = null,
sharedAttachments: List<String> = emptyList(),
sharedCaption: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
var wantsToPostFromCamera by remember { mutableStateOf(false) }
@@ -93,8 +99,8 @@ fun NewPictureButton(
}
val context = LocalContext.current
LaunchedEffect(sharedAttachment) {
resolveSharedMedia(context, sharedAttachment)?.let { pickedURIs = persistentListOf(it) }
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToPostFromCamera) {
@@ -120,6 +126,7 @@ fun NewPictureButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
initialCaption = sharedCaption.orEmpty(),
)
}
@@ -42,13 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.Picture
fun PicturesScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachment: String? = null,
attachments: List<String> = emptyList(),
message: String? = null,
) {
PicturesScreen(
picturesFeedContentState = accountViewModel.feedStates.picturesFeed,
accountViewModel = accountViewModel,
nav = nav,
attachment = attachment,
attachments = attachments,
message = message,
)
}
@@ -57,7 +59,8 @@ fun PicturesScreen(
picturesFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachment: String? = null,
attachments: List<String> = emptyList(),
message: String? = null,
) {
WatchLifecycleAndUpdateModel(picturesFeedContentState)
WatchAccountForPicturesScreen(picturesFeedContentState = picturesFeedContentState, accountViewModel = accountViewModel)
@@ -79,7 +82,7 @@ fun PicturesScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop, attachment)
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop, attachments, message)
}
},
accountViewModel = accountViewModel,
@@ -68,18 +68,22 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* @param sharedAttachment content URI of a video handed over by the "New Short" share target. When
* present the composer opens on it right away.
* The Shorts feed's composer. Every *video* posted from here is a NIP-71 kind-22 short recorded,
* picked or shared alike so landscape footage lands in the Shorts feed too instead of being
* routed to kind 21 by its orientation. Images are unaffected: the gallery picker accepts them and
* they still post as NIP-68 kind-20 pictures, which the Shorts feed does not read.
*
* Everything posted from here is a NIP-71 kind 22 short, whether it was recorded, picked or shared,
* so it always lands in the Shorts feed the composer was opened from landscape footage included.
* @param sharedAttachments content URIs handed over by the "New Short" share target. When non-empty
* the composer opens on them right away.
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
*/
@Composable
fun NewShortVideoButton(
accountViewModel: AccountViewModel,
nav: INav,
navScrollToTop: () -> Unit,
sharedAttachment: String? = null,
sharedAttachments: List<String> = emptyList(),
sharedCaption: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
var wantsToRecordVideo by remember { mutableStateOf(false) }
@@ -96,8 +100,8 @@ fun NewShortVideoButton(
}
val context = LocalContext.current
LaunchedEffect(sharedAttachment) {
resolveSharedMedia(context, sharedAttachment)?.let { pickedURIs = persistentListOf(it) }
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToRecordVideo) {
@@ -124,6 +128,7 @@ fun NewShortVideoButton(
accountViewModel = accountViewModel,
nav = nav,
videoKind = VideoPostKind.SHORT,
initialCaption = sharedCaption.orEmpty(),
)
}
@@ -42,13 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFil
fun ShortsScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachment: String? = null,
attachments: List<String> = emptyList(),
message: String? = null,
) {
ShortsScreen(
shortsFeedContentState = accountViewModel.feedStates.shortsFeed,
accountViewModel = accountViewModel,
nav = nav,
attachment = attachment,
attachments = attachments,
message = message,
)
}
@@ -57,7 +59,8 @@ fun ShortsScreen(
shortsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachment: String? = null,
attachments: List<String> = emptyList(),
message: String? = null,
) {
WatchLifecycleAndUpdateModel(shortsFeedContentState)
WatchAccountForShortsScreen(videoFeedState = shortsFeedContentState, accountViewModel = accountViewModel)
@@ -79,7 +82,7 @@ fun ShortsScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop, attachment)
NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop, attachments, message)
}
},
accountViewModel = accountViewModel,
@@ -68,19 +68,22 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The composer for the Video feed, which renders every media kind. Videos keep the automatic
* kind choice (portrait -> NIP-71 kind 22, landscape -> kind 21); either way they show up here.
* The Video feed's composer. That feed renders every media kind, so videos keep the automatic kind
* choice (portrait -> NIP-71 kind 22, landscape -> kind 21) and pictures post as NIP-68 kind 20
* all three show up here either way, which is why this is the one media composer that pins nothing.
*
* @param sharedAttachment content URI of a video handed over by the "New Video" share target. When
* present the composer opens on it right away, so the share lands as a NIP-71 video event in this
* feed instead of a text note.
* @param sharedAttachments content URIs handed over by the "New Video" share target. When non-empty
* the composer opens on them right away, so the share lands as a NIP-71 video event instead of a
* text note with a link.
* @param sharedCaption text Android sent alongside the files; pre-fills the caption field.
*/
@Composable
fun NewImageButton(
fun NewVideoFeedButton(
accountViewModel: AccountViewModel,
nav: INav,
navScrollToTop: () -> Unit,
sharedAttachment: String? = null,
sharedAttachments: List<String> = emptyList(),
sharedCaption: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
@@ -103,8 +106,8 @@ fun NewImageButton(
}
val context = LocalContext.current
LaunchedEffect(sharedAttachment) {
resolveSharedMedia(context, sharedAttachment)?.let { pickedURIs = persistentListOf(it) }
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToPostFromCamera) {
@@ -137,6 +140,7 @@ fun NewImageButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
initialCaption = sharedCaption.orEmpty(),
)
}
@@ -59,13 +59,15 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
fun VideoScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachment: String? = null,
attachments: List<String> = emptyList(),
message: String? = null,
) {
VideoScreen(
accountViewModel.feedStates.videoFeed,
accountViewModel,
nav,
attachment,
attachments,
message,
)
}
@@ -74,7 +76,8 @@ fun VideoScreen(
videoFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachment: String? = null,
attachments: List<String> = emptyList(),
message: String? = null,
) {
WatchLifecycleAndUpdateModel(videoFeedContentState)
WatchAccountForVideoScreen(videoFeedContentState = videoFeedContentState, accountViewModel = accountViewModel)
@@ -96,7 +99,7 @@ fun VideoScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewImageButton(accountViewModel, nav, videoFeedContentState::sendToTop, attachment)
NewVideoFeedButton(accountViewModel, nav, videoFeedContentState::sendToTop, attachments, message)
}
},
accountViewModel = accountViewModel,
@@ -0,0 +1,135 @@
/*
* 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.navigation
import com.vitorpamplona.amethyst.ui.navigation.ShareIntentRouting
import com.vitorpamplona.amethyst.ui.navigation.ShareTarget
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
/**
* Guards the one link in the share-target chain the compiler cannot see: the `<activity-alias>`
* names in AndroidManifest.xml against the `SHARE_AS_*_ALIAS_SIMPLE_NAME` constants that
* [ShareIntentRouting] matches them by. Renaming an alias on one side only still builds and still
* runs the share just silently opens the wrong composer (a picture share falls through to the
* default "New Post" target and becomes a text note).
*
* Checked in both directions, so neither a renamed constant nor a new alias without a constant
* slips through.
*/
class ShareTargetManifestTest {
private val aliasPattern = Regex("""<activity-alias[^>]*android:name="\.ui\.(\w+)"""", RegexOption.DOT_MATCHES_ALL)
private fun manifest(): String {
// Gradle runs unit tests with the module directory as the working directory; fall back to
// the repo root so the test also passes when run from an IDE configured that way.
val candidates =
listOf(
File("src/main/AndroidManifest.xml"),
File("amethyst/src/main/AndroidManifest.xml"),
)
val found = candidates.firstOrNull { it.isFile }
assertTrue(
"Could not locate AndroidManifest.xml from ${File(".").absolutePath}",
found != null,
)
return found!!.readText()
}
private fun declaredAliases(): Set<String> =
aliasPattern
.findAll(manifest())
.map { it.groupValues[1] }
.toSet()
@Test
fun everyRoutedAliasIsDeclaredInTheManifest() {
val declared = declaredAliases()
val expected =
listOf(
ShareIntentRouting.SHARE_AS_DM_ALIAS_SIMPLE_NAME,
ShareIntentRouting.SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME,
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME,
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME,
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME,
)
expected.forEach {
assertTrue(
"ShareIntentRouting routes \"$it\" but AndroidManifest.xml declares no such " +
"<activity-alias android:name=\".ui.$it\">. Declared: $declared",
it in declared,
)
}
}
@Test
fun everyDeclaredAliasRoutesToItsOwnTarget() {
declaredAliases().forEach { alias ->
val target = ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.$alias")
assertTrue(
"AndroidManifest.xml declares <activity-alias android:name=\".ui.$alias\"> but " +
"ShareIntentRouting has no constant for it, so shares to it fall through to " +
"the default New Post composer.",
target != ShareTarget.NEW_POST,
)
}
}
@Test
fun aliasesResolveToDistinctTargets() {
val aliases = declaredAliases()
val targets = aliases.map { ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.$it") }
assertEquals(
"Two aliases resolve to the same ShareTarget — one of them is shadowed by a " +
"suffix match. Aliases: $aliases, targets: $targets",
targets.size,
targets.toSet().size,
)
}
/** The media targets are the only ones that accept a multi-file selection. */
@Test
fun mediaAliasesAcceptSendMultiple() {
val text = manifest()
listOf(
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME,
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME,
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME,
).forEach { alias ->
val block =
text
.substringAfter("android:name=\".ui.$alias\"")
.substringBefore("</activity-alias>")
assertTrue(
"The \".ui.$alias\" share target has no SEND_MULTIPLE intent-filter, so sharing " +
"several files at once will not offer it.",
block.contains("android.intent.action.SEND_MULTIPLE"),
)
}
}
}