mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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)
|
||||
|
||||
+20
@@ -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()
|
||||
|
||||
|
||||
+4
-2
@@ -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(
|
||||
|
||||
+13
-6
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -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,
|
||||
|
||||
+12
-7
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -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,
|
||||
|
||||
+13
-9
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+7
-4
@@ -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,
|
||||
|
||||
+135
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user