refactor: share-intent ports for the composers

- SharedMediaResolver (rememberSharedMediaResolver) replaces the app's
  resolveSharedMedia(context, …); OnIncomingShare replaces the per-screen
  SEND-intent listeners in ShortNotePostScreen and GenericCommentPostScreen.
- ViewModels log their class via this::class.simpleName.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-30 01:14:01 +00:00
parent 035ed020e4
commit 64dcc7bf71
21 changed files with 294 additions and 176 deletions
@@ -1,63 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.uploads
import android.content.Context
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Resolves a shared content-URI string (from an Android SEND intent) into a
* [SelectedMedia] by reading its MIME type off the main thread. Returns null for
* a null/blank URI. Shared by the share-to-compose pre-fill paths (DM chatroom,
* group DM, …) so the URI→MIME→SelectedMedia logic lives in one place.
*/
suspend fun resolveSharedMedia(
context: Context,
uriString: String?,
): SelectedMedia? =
uriString?.ifBlank { null }?.toUri()?.let { uri ->
withContext(Dispatchers.IO) {
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()
}
}
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.nip22Comments
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
@@ -46,20 +44,14 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.core.util.Consumer
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -79,10 +71,12 @@ import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind
import com.vitorpamplona.amethyst.commons.resources.zapraiser
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.OnIncomingShare
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -114,7 +108,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
@@ -132,9 +125,6 @@ import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun ReplyCommentPostScreen(
@@ -149,7 +139,7 @@ fun ReplyCommentPostScreen(
val postViewModel: CommentPostViewModel = viewModel()
postViewModel.init(accountViewModel)
val context = LocalContext.current
val mediaResolver = rememberSharedMediaResolver()
LaunchedEffect(postViewModel, accountViewModel) {
replyId?.let { LocalCache.getNoteIfExists(it) }?.let {
@@ -165,11 +155,8 @@ fun ReplyCommentPostScreen(
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {
val mediaType = context.contentResolver.getType(it)
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
}
mediaResolver.resolve(attachment)?.let {
postViewModel.selectImage(persistentListOf(it))
}
if (draftId == null) {
postViewModel.applySignature()
@@ -188,32 +175,11 @@ fun GenericCommentPostScreen(
) {
WatchAndLoadMyEmojiList(accountViewModel)
val context = LocalContext.current
val activity = context.getActivity()
val scope = rememberCoroutineScope()
DisposableEffect(nav, activity) {
// Microsoft's swift key sends Gifs as new actions
val consumer =
Consumer<Intent> { intent ->
if (intent.action == Intent.ACTION_SEND) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let {
postViewModel.addToMessage(it)
}
// Use the `intent` parameter (the new intent), not activity.intent (the launch intent).
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri ->
scope.launch(Dispatchers.IO) {
val mediaType = context.contentResolver.getType(uri)
postViewModel.selectImage(persistentListOf(SelectedMedia(uri, mediaType)))
}
}
}
}
activity.addOnNewIntentListener(consumer)
onDispose { activity.removeOnNewIntentListener(consumer) }
}
// Microsoft's swift key sends Gifs as new actions
OnIncomingShare(
onText = postViewModel::addToMessage,
onMedia = { postViewModel.selectImage(persistentListOf(it)) },
)
// NIP-9B: when replying into a NIP-72 community, mount the community feed
// subscription so the latest kind:34551 rules document is fetched and
@@ -466,7 +432,6 @@ private fun GenericCommentPostBody(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
val context = LocalContext.current
ImageVideoDescription(
it,
accountViewModel.account.settings.defaultFileServer,
@@ -157,7 +157,7 @@ fun BuzzWorkspaceOverflowMenu(
)
} catch (e: Exception) {
if (e is CancellationException) throw e
error = e.message ?: e::class.simpleName
error = e.message ?: e.javaClass.simpleName
} finally {
minting = false
}
@@ -34,7 +34,6 @@ 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -47,6 +46,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.reqCommand.event.EventFind
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.chats_history_proto_nip04
import com.vitorpamplona.amethyst.commons.resources.chats_history_proto_nip17
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachDetailDialog
@@ -60,7 +60,6 @@ import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.privateDM.hea
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ChatroomFeedViewModel
@@ -144,10 +143,10 @@ fun ChatroomView(
newPostModel.onMessageChanged()
}
}
val context = LocalContext.current
val mediaResolver = rememberSharedMediaResolver()
if (attachmentUri != null) {
LaunchedEffect(key1 = attachmentUri) {
resolveSharedMedia(context, attachmentUri)?.let {
mediaResolver.resolve(attachmentUri)?.let {
newPostModel.pickedMedia(persistentListOf(it))
}
}
@@ -823,7 +823,7 @@ class ChatNewMessageViewModel :
}
override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
}
// NIP-04 sending is deprecated. NIP-17 is always used.
@@ -718,7 +718,7 @@ class LongFormPostViewModel :
}
override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
}
override fun updateZapPercentage(
@@ -616,7 +616,7 @@ open class NewProductViewModel :
}
override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
}
override fun updateZapPercentage(
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
@@ -55,13 +53,11 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
@@ -72,9 +68,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.core.util.Consumer
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -110,10 +103,12 @@ import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind
import com.vitorpamplona.amethyst.commons.resources.zapraiser
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.OnIncomingShare
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -156,7 +151,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.getActivity
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest
@@ -177,10 +171,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
@Composable
@@ -202,8 +193,7 @@ fun ShortNotePostScreen(
postViewModel.setGroupThread(groupThreadId, groupThreadRelayUrl)
val context = LocalContext.current
val activity = context.getActivity()
val scope = rememberCoroutineScope()
val mediaResolver = rememberSharedMediaResolver()
val proposeAiImprovements by
accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements
@@ -227,39 +217,19 @@ fun ShortNotePostScreen(
postViewModel.message.setTextAndPlaceCursorAtEnd(it)
postViewModel.onMessageChanged()
}
attachment?.ifBlank { null }?.toUri()?.let {
withContext(Dispatchers.IO) {
val mediaType = context.contentResolver.getType(it)
postViewModel.selectImage(persistentListOf(SelectedMedia(it, mediaType)))
}
mediaResolver.resolve(attachment)?.let {
postViewModel.selectImage(persistentListOf(it))
}
if (draftId == null && forkId == null) {
postViewModel.applySignature()
}
}
DisposableEffect(nav, activity) {
// Microsoft's swift key sends Gifs as new actions
val consumer =
Consumer<Intent> { intent ->
if (intent.action == Intent.ACTION_SEND) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let {
postViewModel.addToMessage(it)
}
// Use the `intent` parameter (the new intent), not activity.intent (the launch intent).
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri ->
scope.launch(Dispatchers.IO) {
val mediaType = context.contentResolver.getType(uri)
postViewModel.selectImage(persistentListOf(SelectedMedia(uri, mediaType)))
}
}
}
}
activity.addOnNewIntentListener(consumer)
onDispose { activity.removeOnNewIntentListener(consumer) }
}
// Microsoft's swift key sends Gifs as new actions
OnIncomingShare(
onText = postViewModel::addToMessage,
onMedia = { postViewModel.selectImage(persistentListOf(it)) },
)
NewPostScreenInner(postViewModel, accountViewModel, nav)
}
@@ -610,7 +580,6 @@ private fun NewPostScreenBody(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
val context = LocalContext.current
ImageVideoDescription(
it,
accountViewModel.account.settings.defaultFileServer,
@@ -1995,7 +1995,7 @@ open class ShortNotePostViewModel :
} catch (e: Exception) {
onError(
uploadErrorTitle,
loadStringRes(Res.string.upload_error_voice_message_exception, e.message ?: e.javaClass.simpleName),
loadStringRes(Res.string.upload_error_voice_message_exception, e.message ?: e::class.simpleName.orEmpty()),
)
voiceRecording = null
} finally {
@@ -2015,7 +2015,7 @@ open class ShortNotePostViewModel :
override fun onCleared() {
writingAssistant?.close()
writingAssistant = null
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
}
override fun updateZapPercentage(
@@ -326,6 +326,6 @@ class VoiceReplyViewModel : ViewModel() {
override fun onCleared() {
cancel()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
}
}
@@ -604,7 +604,7 @@ open class NestNewMessageViewModel :
}
override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
}
fun updateZapPercentage(
@@ -555,7 +555,7 @@ private fun broadcastWithDiagnostics(
} catch (ce: CancellationException) {
throw ce
} catch (e: Throwable) {
val klass = e::class.simpleName ?: "Throwable"
val klass = e.javaClass.simpleName ?: "Throwable"
val msg = e.message?.takeIf { it.isNotBlank() } ?: "no message"
accountViewModel.toastManager.toast(failedTitle, "$klass: $msg")
}
@@ -670,7 +670,7 @@ class NewPublicMessageViewModel :
}
override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
}
override fun updateZapPercentage(
@@ -41,7 +41,6 @@ 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.commons.icons.symbols.Icon
@@ -54,6 +53,7 @@ import com.vitorpamplona.amethyst.commons.resources.upload_image
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakePicture
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.painterRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -62,7 +62,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -102,9 +101,9 @@ fun NewPictureButton(
}
}
val context = LocalContext.current
val mediaResolver = rememberSharedMediaResolver()
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
mediaResolver.resolve(sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToPostFromCamera) {
@@ -41,7 +41,6 @@ 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.commons.icons.symbols.Icon
@@ -55,6 +54,7 @@ import com.vitorpamplona.amethyst.commons.resources.upload_image
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideo
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.painterRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -63,7 +63,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -103,9 +102,9 @@ fun NewShortVideoButton(
}
}
val context = LocalContext.current
val mediaResolver = rememberSharedMediaResolver()
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
mediaResolver.resolve(sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToRecordVideo) {
@@ -41,7 +41,6 @@ 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.commons.icons.symbols.Icon
@@ -56,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakePicture
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.TakeVideo
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.rememberSharedMediaResolver
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.painterRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -64,7 +64,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.uploads.resolveSharedMedia
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -110,9 +109,9 @@ fun NewVideoFeedButton(
}
}
val context = LocalContext.current
val mediaResolver = rememberSharedMediaResolver()
LaunchedEffect(sharedAttachments) {
resolveSharedMedia(context, sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
mediaResolver.resolve(sharedAttachments).takeIf { it.isNotEmpty() }?.let { pickedURIs = it }
}
if (wantsToPostFromCamera) {
@@ -279,7 +279,7 @@ class HlsPublishOrchestrator(
// scope; NewHlsVideoViewModel.cancel() calls reset() to put state back to Idle.
throw e
} catch (e: Throwable) {
_state.value = HlsPublishState.Failure(message = e.message ?: e::class.simpleName.orEmpty())
_state.value = HlsPublishState.Failure(message = e.message ?: e.javaClass.simpleName.orEmpty())
}
}
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.actions.uploads
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.core.util.Consumer
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private class ContentResolverSharedMedia(
private val context: Context,
) : SharedMediaResolver {
override suspend fun resolve(uriString: String?): SelectedMedia? =
uriString?.ifBlank { null }?.toUri()?.let { uri ->
withContext(Dispatchers.IO) {
SelectedMedia(uri, context.contentResolver.getType(uri))
}
}
// One trip to the IO dispatcher for the whole batch instead of one context switch per file.
override suspend fun resolve(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()
}
}
}
@Composable
actual fun rememberSharedMediaResolver(): SharedMediaResolver {
val context = LocalContext.current.applicationContext
return remember(context) { ContentResolverSharedMedia(context) }
}
private tailrec fun Context.findComponentActivity(): ComponentActivity? =
when (this) {
is ComponentActivity -> this
is ContextWrapper -> baseContext.findComponentActivity()
else -> null
}
@Composable
actual fun OnIncomingShare(
onText: (String) -> Unit,
onMedia: (SelectedMedia) -> Unit,
) {
val context = LocalContext.current
val activity = context.findComponentActivity() ?: return
val scope = rememberCoroutineScope()
val currentOnText by rememberUpdatedState(onText)
val currentOnMedia by rememberUpdatedState(onMedia)
DisposableEffect(activity) {
val consumer =
Consumer<Intent> { intent ->
if (intent.action == Intent.ACTION_SEND) {
intent.getStringExtra(Intent.EXTRA_TEXT)?.ifBlank { null }?.let {
currentOnText(it)
}
// Use the `intent` parameter (the new intent), not activity.intent (the launch intent).
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri ->
scope.launch(Dispatchers.IO) {
val mediaType = context.contentResolver.getType(uri)
currentOnMedia(SelectedMedia(uri, mediaType))
}
}
}
}
activity.addOnNewIntentListener(consumer)
onDispose { activity.removeOnNewIntentListener(consumer) }
}
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.actions.uploads
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import kotlinx.collections.immutable.ImmutableList
/**
* Turns the file addresses another app shared with this one (an Android SEND intent's
* `content://` URIs) into [SelectedMedia], reading each MIME type off the main thread.
*/
interface SharedMediaResolver {
/** Null for a null or blank address. */
suspend fun resolve(uriString: String?): SelectedMedia?
/** Batch form for multi-file shares; blank entries are dropped. */
suspend fun resolve(uriStrings: List<String>): ImmutableList<SelectedMedia>
}
@Composable
expect fun rememberSharedMediaResolver(): SharedMediaResolver
/**
* Calls [onText] and [onMedia] for content shared into the app while this screen is showing (a
* keyboard sending a GIF as a new share, say). Android listens for new SEND intents; the other
* platforms receive none yet.
*/
@Composable
expect fun OnIncomingShare(
onText: (String) -> Unit,
onMedia: (SelectedMedia) -> Unit,
)
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.actions.uploads
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.StringMediaUri
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
// Addresses are plain paths or URLs here; nothing reports a MIME type for them yet.
private object StringSharedMedia : SharedMediaResolver {
override suspend fun resolve(uriString: String?): SelectedMedia? = uriString?.ifBlank { null }?.let { SelectedMedia(StringMediaUri(it), null) }
override suspend fun resolve(uriStrings: List<String>): ImmutableList<SelectedMedia> = uriStrings.mapNotNull { resolve(it) }.toImmutableList()
}
@Composable
actual fun rememberSharedMediaResolver(): SharedMediaResolver = StringSharedMedia
/** No app shares into this one on this platform yet. */
@Composable
actual fun OnIncomingShare(
onText: (String) -> Unit,
onMedia: (SelectedMedia) -> Unit,
) = Unit
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.actions.uploads
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.StringMediaUri
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
// Addresses are plain paths or URLs here; nothing reports a MIME type for them yet.
private object StringSharedMedia : SharedMediaResolver {
override suspend fun resolve(uriString: String?): SelectedMedia? = uriString?.ifBlank { null }?.let { SelectedMedia(StringMediaUri(it), null) }
override suspend fun resolve(uriStrings: List<String>): ImmutableList<SelectedMedia> = uriStrings.mapNotNull { resolve(it) }.toImmutableList()
}
@Composable
actual fun rememberSharedMediaResolver(): SharedMediaResolver = StringSharedMedia
/** No app shares into this one on this platform yet. */
@Composable
actual fun OnIncomingShare(
onText: (String) -> Unit,
onMedia: (SelectedMedia) -> Unit,
) = Unit