Merge pull request #3825 from vitorpamplona/claude/intent-receivers-feed-sharing-rkwiv1

Add media share targets for Pictures, Shorts, and Video feeds
This commit is contained in:
Vitor Pamplona
2026-07-30 12:22:44 -04:00
committed by GitHub
21 changed files with 689 additions and 105 deletions
+69
View File
@@ -295,6 +295,75 @@
</intent-filter>
</activity-alias>
<!-- "New Picture" share target: a gallery (or camera) shares an image and it goes straight
into the picture feed as a NIP-68 kind-20 post instead of a text note with a link. The
android:name simple class ("ShareAsPictureAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsPictureAlias"
android:exported="true"
android:label="@string/share_target_as_picture"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_picture">
<action android:name="android.intent.action.SEND" />
<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
it lands in the Shorts feed, whatever its orientation. Video-only — a short is a video.
The android:name simple class ("ShareAsShortVideoAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsShortVideoAlias"
android:exported="true"
android:label="@string/share_target_as_short_video"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_short_video">
<action android:name="android.intent.action.SEND" />
<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
NIP-71 video event (kind 21 or 22 by orientation) instead of a text note. The
android:name simple class ("ShareAsVideoAlias") is matched at runtime by
ShareIntentRouting.SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME; keep the two in sync. -->
<activity-alias
android:name=".ui.ShareAsVideoAlias"
android:exported="true"
android:label="@string/share_target_as_video"
android:targetActivity=".ui.MainActivity">
<intent-filter android:label="@string/share_target_as_video">
<action android:name="android.intent.action.SEND" />
<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
text and it opens the NIP-84 highlight composer. Text-only — a highlight is a text
passage, so no image/video filters here. The android:name simple class
@@ -4145,6 +4145,7 @@ class Account(
alt: String?,
contentWarningReason: String?,
originalHash: String? = null,
videoKind: VideoPostKind = VideoPostKind.AUTO,
) {
if (!isWriteable()) return
@@ -4188,7 +4189,10 @@ class Account(
thumbhash = headerInfo.thumbHash?.thumbhash,
)
if (headerInfo.dim.height > headerInfo.dim.width) {
// The composer forces the kind when it was opened from a feed that only reads one of
// them (Shorts, Longs) or from that feed's share target, so the post lands where the
// user asked for it. Everywhere else the orientation decides.
if (videoKind.isShort(headerInfo.dim)) {
VideoShortEvent.build(videoMeta, alt ?: "") {
contentWarningReason?.let { contentWarning(contentWarningReason) }
}
@@ -0,0 +1,53 @@
/*
* 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.model
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
/**
* Which NIP-71 kind a video upload is published as. Only videos are affected — whether an upload
* is a picture (NIP-68 kind 20) or a video is decided by the file's mime type and can't be
* overridden.
*
* The composer picks this from the feed it was opened on, so a post always lands in the feed the
* user was standing in (or shared to): the Shorts feed reads kind 22, the Longs feed reads kind 21
* and the Video feed reads both.
*/
enum class VideoPostKind {
/** Derive from the video's dimensions: portrait -> kind 22, landscape -> kind 21. */
AUTO,
/** Always NIP-71 kind 22 (short-form video), regardless of orientation. */
SHORT,
/** Always NIP-71 kind 21 (normal video), regardless of orientation. */
NORMAL,
;
/** True when a video of [dim] should be published as a NIP-71 kind 22 short. */
fun isShort(dim: DimensionTag): Boolean =
when (this) {
SHORT -> true
NORMAL -> false
AUTO -> dim.height > dim.width
}
}
@@ -31,6 +31,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
@@ -73,15 +74,21 @@ open class NewMediaModel : ViewModel() {
// Strip location and sensitive metadata from files before upload
var stripMetadata by mutableStateOf(true)
// Which NIP-71 kind videos in this batch are published as. Set by the composer's host feed.
var videoKind: VideoPostKind = VideoPostKind.AUTO
open fun load(
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()
this.stripMetadata = account.settings.stripLocationOnUpload
this.videoKind = videoKind
}
fun isImage(
@@ -180,6 +187,7 @@ open class NewMediaModel : ViewModel() {
alt = caption,
contentWarningReason = if (sensitiveContent) "" else null,
originalHash = it.uploadedHash,
videoKind = videoKind,
)
}
}
@@ -54,6 +54,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.VideoPostKind
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
@@ -79,14 +80,16 @@ fun NewMediaView(
postViewModel: NewMediaModel,
accountViewModel: AccountViewModel,
nav: INav,
videoKind: VideoPostKind = VideoPostKind.AUTO,
initialCaption: String = "",
) {
val account = accountViewModel.account
val context = LocalContext.current
val scrollState = rememberScrollState()
LaunchedEffect(uris) {
postViewModel.load(account, uris)
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) }
composableCapped<Route.Video> { VideoScreen(accountViewModel, nav) }
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) }
composableFromEnd<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
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) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
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,49 +1024,43 @@ private fun NavigateIfIntentRequested(
val activity = LocalContext.current.getActivity()
if (activity.intent.action == Intent.ACTION_SEND) {
val isShareAsDm = ShareIntentRouting.isShareAsDm(activity.intent.component?.className)
val isShareAsHighlight = ShareIntentRouting.isShareAsHighlight(activity.intent.component?.className)
if (activity.intent.isShareAction()) {
val target = ShareIntentRouting.targetOf(activity.intent.component?.className)
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
if (isShareAsHighlight) {
if (isBaseRoute<Route.NewHighlight>(nav.controller)) return
} else if (isShareAsDm) {
if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
} else {
if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
// Microsoft's swift key sends Gifs as new actions.
// The media targets land on a feed the user may well be standing on already, so they can't
// guard on the destination — they rely on the intent being consumed below instead.
when (target) {
ShareTarget.HIGHLIGHT -> if (isBaseRoute<Route.NewHighlight>(nav.controller)) return
ShareTarget.DIRECT_MESSAGE -> if (isBaseRoute<Route.ShareToDM>(nav.controller)) return
ShareTarget.NEW_POST -> if (isBaseRoute<Route.NewShortNote>(nav.controller)) return
ShareTarget.PICTURE, ShareTarget.SHORT_VIDEO, ShareTarget.VIDEO -> Unit
}
// saves the intent to avoid processing again
var message by remember {
mutableStateOf(
activity.intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
it.ifBlank { null }
},
)
}
val message = remember { activity.intent.sharedText() }
val attachments = remember { activity.intent.sharedStreamUris() }
var media by remember {
mutableStateOf(
IntentCompat.getParcelableExtra(activity.intent, Intent.EXTRA_STREAM, Uri::class.java),
)
}
if (isShareAsHighlight) {
val parsed = message?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
} else if (isShareAsDm) {
nav.newStack(Route.ShareToDM(message = message, attachment = media?.toString()))
} else {
nav.newStack(Route.NewShortNote(message = message, attachment = media.toString()))
when (target) {
ShareTarget.HIGHLIGHT -> {
val parsed = message?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
}
// 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
@@ -1096,38 +1127,44 @@ private fun NavigateIfIntentRequested(
DisposableEffect(nav, activity) {
val consumer =
Consumer<Intent> { intent ->
if (intent.action == Intent.ACTION_SEND) {
val isShareAsDm = ShareIntentRouting.isShareAsDm(intent.component?.className)
val isShareAsHighlight = ShareIntentRouting.isShareAsHighlight(intent.component?.className)
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions
if (isShareAsHighlight) {
if (!isBaseRoute<Route.NewHighlight>(nav.controller)) {
val parsed = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
}
} else if (isShareAsDm) {
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
val message = intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }
val attachment =
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.toString()
nav.newStack(Route.ShareToDM(message = message, attachment = attachment))
}
} else if (!isBaseRoute<Route.NewShortNote>(nav.controller)) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
nav.newStack(Route.NewShortNote(message = it))
}
if (intent.isShareAction()) {
val target = ShareIntentRouting.targetOf(intent.component?.className)
val message = intent.sharedText()
val attachments = intent.sharedStreamUris()
val attachment = attachments.firstOrNull()
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let {
nav.newStack(Route.NewShortNote(attachment = it.toString()))
}
// avoids restarting the destination screen when the intent is for the screen.
// Microsoft's swift key sends Gifs as new actions.
// The media targets land on a feed the user may well be standing on already, so
// they always navigate: the route carries the attachment, so the composer opens
// on the newly shared file even when the feed itself is already on screen.
when (target) {
ShareTarget.HIGHLIGHT ->
if (!isBaseRoute<Route.NewHighlight>(nav.controller)) {
val parsed = message?.let { SharedHighlightParser.parse(it) }
nav.newStack(
Route.NewHighlight(
quote = parsed?.quote,
url = parsed?.url,
prefix = parsed?.prefix,
suffix = parsed?.suffix,
),
)
}
ShareTarget.DIRECT_MESSAGE ->
if (!isBaseRoute<Route.ShareToDM>(nav.controller)) {
nav.newStack(Route.ShareToDM(message = message, 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)) {
nav.newStack(Route.NewShortNote(message = message, attachment = attachment))
}
}
} else {
val uri = intent.data?.toString()
@@ -20,10 +20,31 @@
*/
package com.vitorpamplona.amethyst.ui.navigation
/** Which entry of Android's share sheet the user picked. */
enum class ShareTarget {
/** The default "New Post" target: a kind-1 note with the shared text/media. */
NEW_POST,
/** "Send as DM": a NIP-17 private message. */
DIRECT_MESSAGE,
/** "New Highlight": a NIP-84 highlight of the shared passage. */
HIGHLIGHT,
/** "New Picture": a NIP-68 picture post, straight into the picture feed. */
PICTURE,
/** "New Short": a NIP-71 kind 22 video, straight into the Shorts feed. */
SHORT_VIDEO,
/** "New Video": a NIP-71 video, straight into the Video feed. */
VIDEO,
}
/**
* Distinguishes the extra share targets ("Send as DM", "New Highlight") from the default
* "New Post" share target. Every SEND intent-filter resolves to MainActivity; they are told
* apart by the component class name of the launching intent (the activity-alias name).
* Tells the share targets apart. Every SEND intent-filter resolves to MainActivity; which target
* the user picked is only visible in the component class name of the launching intent (the
* `<activity-alias>` name), so each alias below maps to the composer it opens.
*/
object ShareIntentRouting {
/**
@@ -41,7 +62,35 @@ object ShareIntentRouting {
*/
const val SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME = "ShareAsHighlightAlias"
fun isShareAsDm(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_DM_ALIAS_SIMPLE_NAME") == true
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME = "ShareAsPictureAlias"
fun isShareAsHighlight(componentClassName: String?): Boolean = componentClassName?.endsWith(".$SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME") == true
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsShortVideoAlias"
/** See the caveat on [SHARE_AS_DM_ALIAS_SIMPLE_NAME]. */
const val SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME = "ShareAsVideoAlias"
private val TARGET_BY_ALIAS =
mapOf(
SHARE_AS_DM_ALIAS_SIMPLE_NAME to ShareTarget.DIRECT_MESSAGE,
SHARE_AS_HIGHLIGHT_ALIAS_SIMPLE_NAME to ShareTarget.HIGHLIGHT,
SHARE_AS_PICTURE_ALIAS_SIMPLE_NAME to ShareTarget.PICTURE,
SHARE_AS_SHORT_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.SHORT_VIDEO,
SHARE_AS_VIDEO_ALIAS_SIMPLE_NAME to ShareTarget.VIDEO,
)
/**
* Resolves the launching component to its target. Flavors can change the resolved package
* prefix, so the match is on the simple name; anything that isn't one of our aliases (chiefly
* MainActivity itself) is the default New Post target.
*/
fun targetOf(componentClassName: String?): ShareTarget {
if (componentClassName == null) return ShareTarget.NEW_POST
return TARGET_BY_ALIAS.entries
.firstOrNull { componentClassName.endsWith(".${it.key}") }
?.value
?: ShareTarget.NEW_POST
}
}
@@ -112,7 +112,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
id = NavBarItem.VIDEO,
labelRes = R.string.route_video,
icon = MaterialSymbols.Subscriptions,
resolveRoute = { Route.Video },
resolveRoute = { Route.Video() },
),
NavBarItem.DISCOVER to
NavBarItemDef(
@@ -231,7 +231,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
id = NavBarItem.PICTURES,
labelRes = R.string.pictures,
icon = MaterialSymbols.Photo,
resolveRoute = { Route.Pictures },
resolveRoute = { Route.Pictures() },
),
NavBarItem.WORKOUTS to
NavBarItemDef(
@@ -308,7 +308,7 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
id = NavBarItem.SHORTS,
labelRes = R.string.shorts,
icon = MaterialSymbols.PlayCircle,
resolveRoute = { Route.Shorts },
resolveRoute = { Route.Shorts() },
),
NavBarItem.MUSIC_TRACKS to
NavBarItemDef(
@@ -30,12 +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()
@Serializable object Video : Route()
/**
* 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(
override val attachments: List<String> = emptyList(),
override val message: String? = null,
) : Route(),
MediaFeedRoute
@Serializable data class Discover(
val initialTab: DiscoverTab? = null,
@@ -81,7 +101,16 @@ sealed class Route {
)
}
@Serializable object Pictures : Route()
/**
* 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(
override val attachments: List<String> = emptyList(),
override val message: String? = null,
) : Route(),
MediaFeedRoute
@Serializable object Workouts : Route()
@@ -175,7 +204,16 @@ sealed class Route {
@Serializable object Products : Route()
@Serializable object Shorts : Route()
/**
* 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(
override val attachments: List<String> = emptyList(),
override val message: String? = null,
) : Route(),
MediaFeedRoute
@Serializable object PublicChats : Route()
@@ -45,6 +45,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
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.model.VideoPostKind
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
@@ -63,6 +64,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 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(
accountViewModel: AccountViewModel,
@@ -106,6 +113,7 @@ fun NewLongVideoButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
videoKind = VideoPostKind.NORMAL,
)
}
@@ -34,12 +34,14 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -50,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -63,11 +66,23 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 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,
sharedAttachments: List<String> = emptyList(),
sharedCaption: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
var wantsToPostFromCamera by remember { mutableStateOf(false) }
@@ -83,6 +98,11 @@ fun NewPictureButton(
}
}
val context = LocalContext.current
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToPostFromCamera) {
TakePicture { uri ->
wantsToPostFromCamera = false
@@ -106,6 +126,7 @@ fun NewPictureButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
initialCaption = sharedCaption.orEmpty(),
)
}
@@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.Picture
fun PicturesScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
PicturesScreen(
picturesFeedContentState = accountViewModel.feedStates.picturesFeed,
accountViewModel = accountViewModel,
nav = nav,
attachments = attachments,
message = message,
)
}
@@ -55,6 +59,8 @@ fun PicturesScreen(
picturesFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
WatchLifecycleAndUpdateModel(picturesFeedContentState)
WatchAccountForPicturesScreen(picturesFeedContentState = picturesFeedContentState, accountViewModel = accountViewModel)
@@ -66,8 +72,8 @@ fun PicturesScreen(
PicturesTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Pictures, nav, accountViewModel) { route ->
if (route == Route.Pictures) {
AppBottomBar(Route.Pictures(), nav, accountViewModel) { route ->
if (route is Route.Pictures) {
picturesFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
@@ -76,7 +82,7 @@ fun PicturesScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop)
NewPictureButton(accountViewModel, nav, picturesFeedContentState::sendToTop, attachments, message)
}
},
accountViewModel = accountViewModel,
@@ -34,22 +34,26 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
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.model.VideoPostKind
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideo
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -63,11 +67,23 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 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.
*
* @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,
sharedAttachments: List<String> = emptyList(),
sharedCaption: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
var wantsToRecordVideo by remember { mutableStateOf(false) }
@@ -83,6 +99,11 @@ fun NewShortVideoButton(
}
}
val context = LocalContext.current
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToRecordVideo) {
TakeVideo { uri ->
wantsToRecordVideo = false
@@ -106,6 +127,8 @@ fun NewShortVideoButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
videoKind = VideoPostKind.SHORT,
initialCaption = sharedCaption.orEmpty(),
)
}
@@ -42,11 +42,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.datasource.ShortsFil
fun ShortsScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
ShortsScreen(
shortsFeedContentState = accountViewModel.feedStates.shortsFeed,
accountViewModel = accountViewModel,
nav = nav,
attachments = attachments,
message = message,
)
}
@@ -55,6 +59,8 @@ fun ShortsScreen(
shortsFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
WatchLifecycleAndUpdateModel(shortsFeedContentState)
WatchAccountForShortsScreen(videoFeedState = shortsFeedContentState, accountViewModel = accountViewModel)
@@ -66,8 +72,8 @@ fun ShortsScreen(
ShortsTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Shorts, nav, accountViewModel) { route ->
if (route == Route.Shorts) {
AppBottomBar(Route.Shorts(), nav, accountViewModel) { route ->
if (route is Route.Shorts) {
shortsFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
@@ -76,7 +82,7 @@ fun ShortsScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop)
NewShortVideoButton(accountViewModel, nav, shortsFeedContentState::sendToTop, attachments, message)
}
},
accountViewModel = accountViewModel,
@@ -34,12 +34,14 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
@@ -51,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.TakePicture
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideo
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -64,11 +67,23 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 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 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,
sharedAttachments: List<String> = emptyList(),
sharedCaption: String? = null,
) {
var isOpen by remember { mutableStateOf(false) }
@@ -90,6 +105,11 @@ fun NewImageButton(
}
}
val context = LocalContext.current
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToPostFromCamera) {
TakePicture { uri ->
wantsToPostFromCamera = false
@@ -120,6 +140,7 @@ fun NewImageButton(
postViewModel = postViewModel,
accountViewModel = accountViewModel,
nav = nav,
initialCaption = sharedCaption.orEmpty(),
)
}
@@ -59,11 +59,15 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
fun VideoScreen(
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
VideoScreen(
accountViewModel.feedStates.videoFeed,
accountViewModel,
nav,
attachments,
message,
)
}
@@ -72,6 +76,8 @@ fun VideoScreen(
videoFeedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
attachments: List<String> = emptyList(),
message: String? = null,
) {
WatchLifecycleAndUpdateModel(videoFeedContentState)
WatchAccountForVideoScreen(videoFeedContentState = videoFeedContentState, accountViewModel = accountViewModel)
@@ -83,8 +89,8 @@ fun VideoScreen(
StoriesTopBar(accountViewModel, nav)
},
bottomBar = {
AppBottomBar(Route.Video, nav, accountViewModel) { route ->
if (route == Route.Video) {
AppBottomBar(Route.Video(), nav, accountViewModel) { route ->
if (route is Route.Video) {
videoFeedContentState.sendToTop()
} else {
nav.navBottomBar(route)
@@ -93,7 +99,7 @@ fun VideoScreen(
},
floatingButton = {
FabBottomBarPadded(nav) {
NewImageButton(accountViewModel, nav, videoFeedContentState::sendToTop)
NewVideoFeedButton(accountViewModel, nav, videoFeedContentState::sendToTop, attachments, message)
}
},
accountViewModel = accountViewModel,
+3
View File
@@ -2772,6 +2772,9 @@
<string name="share_to_dm_title">Send to…</string>
<string name="share_to_dm_start_new">New message</string>
<string name="share_target_as_highlight">New Highlight</string>
<string name="share_target_as_picture">New Picture</string>
<string name="share_target_as_short_video">New Short</string>
<string name="share_target_as_video">New Video</string>
<string name="new_highlight_title">New Highlight</string>
<string name="new_highlight_passage_label">Highlighted text</string>
<string name="new_highlight_passage_placeholder">What stood out to you?</string>
@@ -0,0 +1,56 @@
/*
* 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.model
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class VideoPostKindTest {
private val portrait = DimensionTag(1080, 1920)
private val landscape = DimensionTag(1920, 1080)
private val square = DimensionTag(1080, 1080)
@Test
fun autoFollowsTheOrientation() {
assertTrue(VideoPostKind.AUTO.isShort(portrait))
assertFalse(VideoPostKind.AUTO.isShort(landscape))
// A square video is not taller than it is wide, so it stays a normal video.
assertFalse(VideoPostKind.AUTO.isShort(square))
}
@Test
fun shortStaysShortEvenWhenTheFootageIsLandscape() {
// The Shorts feed only reads kind 22. A landscape video shared to the "New Short" target
// (or recorded from the Shorts composer) has to be a short or it never shows up there.
assertTrue(VideoPostKind.SHORT.isShort(landscape))
assertTrue(VideoPostKind.SHORT.isShort(portrait))
assertTrue(VideoPostKind.SHORT.isShort(square))
}
@Test
fun normalStaysNormalEvenWhenTheFootageIsPortrait() {
assertFalse(VideoPostKind.NORMAL.isShort(portrait))
assertFalse(VideoPostKind.NORMAL.isShort(landscape))
assertFalse(VideoPostKind.NORMAL.isShort(square))
}
}
@@ -21,34 +21,52 @@
package com.vitorpamplona.amethyst.navigation
import com.vitorpamplona.amethyst.ui.navigation.ShareIntentRouting
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import com.vitorpamplona.amethyst.ui.navigation.ShareTarget
import org.junit.Assert.assertEquals
import org.junit.Test
class ShareIntentRoutingTest {
@Test
fun detectsAliasByExactClassName() {
assertTrue(ShareIntentRouting.isShareAsDm("com.vitorpamplona.amethyst.ui.ShareAsDMAlias"))
assertEquals(ShareTarget.DIRECT_MESSAGE, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsDMAlias"))
}
@Test
fun detectsAliasRegardlessOfPackagePrefix() {
// Flavors can change the resolved package prefix; match on the simple name.
assertTrue(ShareIntentRouting.isShareAsDm("com.example.fork.ui.ShareAsDMAlias"))
assertEquals(ShareTarget.DIRECT_MESSAGE, ShareIntentRouting.targetOf("com.example.fork.ui.ShareAsDMAlias"))
}
@Test
fun rejectsMainActivity() {
assertFalse(ShareIntentRouting.isShareAsDm("com.vitorpamplona.amethyst.ui.MainActivity"))
fun detectsEveryAlias() {
assertEquals(ShareTarget.HIGHLIGHT, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsHighlightAlias"))
assertEquals(ShareTarget.PICTURE, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsPictureAlias"))
assertEquals(ShareTarget.SHORT_VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsShortVideoAlias"))
assertEquals(ShareTarget.VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsVideoAlias"))
}
@Test
fun rejectsNull() {
assertFalse(ShareIntentRouting.isShareAsDm(null))
fun mainActivityIsTheDefaultNewPostTarget() {
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.MainActivity"))
}
@Test
fun nullIsTheDefaultNewPostTarget() {
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf(null))
}
@Test
fun rejectsSuffixThatIsNotASimpleName() {
assertFalse(ShareIntentRouting.isShareAsDm("com.evil.XShareAsDMAlias"))
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf("com.evil.XShareAsDMAlias"))
assertEquals(ShareTarget.NEW_POST, ShareIntentRouting.targetOf("com.evil.XShareAsVideoAlias"))
}
/**
* "ShareAsShortVideoAlias" also ends with "VideoAlias", so a sloppier match would route every
* short to the plain video composer (kind 21 instead of 22 — the wrong feed).
*/
@Test
fun shortVideoAliasDoesNotCollideWithTheVideoAlias() {
assertEquals(ShareTarget.SHORT_VIDEO, ShareIntentRouting.targetOf("com.vitorpamplona.amethyst.ui.ShareAsShortVideoAlias"))
}
}
@@ -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"),
)
}
}
}