From ad6bb4d7435a9e09d0e01a422ad06d875f3897f8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 15:31:27 -0400 Subject: [PATCH 1/3] feat(shareable): send an invitation with the group invite link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group invite share put the bare URL in EXTRA_TEXT, so the recipient got a link and nothing saying what it was. iOS' GroupInviteSheet now names the group ahead of the link, and this matches it: "Join on Flipcash and let's chat", a blank line, then the URL. The blank line is what keeps the URL unfurling on its own rather than running into the sentence. A group with no title has nothing to name, so it keeps sharing the bare link. EXTRA_TITLE/EXTRA_SUBJECT carry the same sentence, the way shareToken uses title_shareToken in both slots. message_groupInvite replaces subtitle_shareGroupInvite, which was drafted for this slot and never referenced. The copy action is untouched — ChatViewModel still copies the URL verbatim, since a sentence pasted into a URL bar is worse than the gap being closed. Adds the module's first test source set; both cases are asserted through the chooser's EXTRA_INTENT under Robolectric. --- .../core/src/main/res/values/strings.xml | 3 +- .../shared/shareable/build.gradle.kts | 5 ++ .../app/shareable/ShareSheetController.kt | 2 +- .../internal/InternalShareSheetController.kt | 13 +++- .../internal/ShareGroupInviteTest.kt | 75 +++++++++++++++++++ 5 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index fca79ba7b5..dbb8464f8d 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1019,7 +1019,8 @@ <string name="action_sendInviteLink">Send Invite Link</string> <string name="action_copyInviteLink">Copy Invite Link</string> <string name="action_invitePeopleToJoin">Invite People To Join</string> - <string name="subtitle_shareGroupInvite">Join me on Flipcash</string> + <!-- Sent ahead of the invite link in the share sheet; %1$s is the group title. Same wording as iOS. --> + <string name="message_groupInvite">Join %1$s on Flipcash and let\'s chat</string> <string name="title_tipIntro">Receive Tips From Everyone</string> <string name="subtitle_tipIntro">Add your name to receive tips</string> <string name="action_startReceivingTips">Start Receiving Tips</string> diff --git a/apps/flipcash/shared/shareable/build.gradle.kts b/apps/flipcash/shared/shareable/build.gradle.kts index e724390d37..574501ca49 100644 --- a/apps/flipcash/shared/shareable/build.gradle.kts +++ b/apps/flipcash/shared/shareable/build.gradle.kts @@ -7,6 +7,11 @@ android { } dependencies { + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) + testImplementation(testFixtures(project(":ui:resources"))) + implementation(libs.bundles.kotlinx.serialization) implementation(libs.androidx.localbroadcastmanager) diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt index 2871188402..fa9bfad696 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt @@ -54,7 +54,7 @@ sealed interface Shareable { */ data class GroupInvite( val url: String, - // The group's title, for the Sharesheet heading. Null shares the link with no heading. + // The group's name, which the invitation sentence names. Null shares the bare link. val title: String? = null, ) : Shareable { override val pendingData: ShareablePendingData? = null diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt index e545f1b25e..83efcefd34 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt @@ -298,14 +298,21 @@ internal class InternalShareSheetController( } private fun shareGroupInvite(shareable: Shareable.GroupInvite) { + // The invitation names the group, so with no title there is nothing to say — share the bare + // link rather than an invitation with a hole in it. Same wording as iOS' GroupInviteSheet. + val invitation = shareable.title + ?.let { resources.getString(R.string.message_groupInvite, it) } + val intent = Intent().apply { action = Intent.ACTION_SEND - // No heading when the group has no title yet — an empty one reads as a broken share. - shareable.title?.let { + invitation?.let { putExtra(Intent.EXTRA_TITLE, it) putExtra(Intent.EXTRA_SUBJECT, it) } - putExtra(Intent.EXTRA_TEXT, shareable.url) + putExtra( + Intent.EXTRA_TEXT, + if (invitation != null) "$invitation\n\n${shareable.url}" else shareable.url + ) type = "text/plain" } diff --git a/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt b/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt new file mode 100644 index 0000000000..d16d603f6d --- /dev/null +++ b/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt @@ -0,0 +1,75 @@ +package com.flipcash.app.shareable.internal + +import android.app.Application +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.shareable.Shareable +import com.flipcash.shared.shareable.R +import com.getcode.util.resources.FakeResourceHelper +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The shared text is what the recipient reads, so it is asserted verbatim — the blank line between + * the invitation and the link is what keeps the URL unfurling on its own instead of running into + * the sentence. iOS' GroupInviteSheet sends the same two lines. + */ +@RunWith(RobolectricTestRunner::class) +class ShareGroupInviteTest { + + private val application: Application = ApplicationProvider.getApplicationContext() + + private val resources = FakeResourceHelper() + .stub(R.string.message_groupInvite, "Join %1\$s on Flipcash and let's chat") + + private val controller = InternalShareSheetController( + context = application, + clipboardManager = application.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager, + resources = resources, + ) + + @Test + fun `titled group shares the invitation above the link`() = runTest { + val sent = present( + Shareable.GroupInvite(url = "https://app.flipcash.com/chat/abc", title = "Book Club") + ) + + assertEquals( + "Join Book Club on Flipcash and let's chat\n\nhttps://app.flipcash.com/chat/abc", + sent.getStringExtra(Intent.EXTRA_TEXT) + ) + assertEquals( + "Join Book Club on Flipcash and let's chat", + sent.getStringExtra(Intent.EXTRA_TITLE) + ) + assertEquals( + "Join Book Club on Flipcash and let's chat", + sent.getStringExtra(Intent.EXTRA_SUBJECT) + ) + } + + @Test + fun `untitled group shares the bare link`() = runTest { + val sent = present( + Shareable.GroupInvite(url = "https://app.flipcash.com/chat/abc", title = null) + ) + + assertEquals("https://app.flipcash.com/chat/abc", sent.getStringExtra(Intent.EXTRA_TEXT)) + assertNull(sent.getStringExtra(Intent.EXTRA_TITLE)) + assertNull(sent.getStringExtra(Intent.EXTRA_SUBJECT)) + } + + /** Presents [shareable] and returns the intent the chooser was built around. */ + private suspend fun present(shareable: Shareable): Intent { + controller.present(shareable) + val chooser = shadowOf(application).nextStartedActivity + return requireNotNull(chooser.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)) + } +} From 6edcf08a2aca40489c40d8007fab971d8a83e20b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Fri, 18 Sep 2026 15:44:53 -0400 Subject: [PATCH 2/3] feat(messenger): match iOS' invite icon and preview the group's picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things about the group invite. The profile row's icon was ic_group_3, a three-person glyph naming a group rather than the act of adding to one. iOS' same row draws person.badge.plus, so this draws Icons.Outlined.PersonAdd — the Material glyph closest to it, and the same kind of vector the Leave row beside it already uses. The Sharesheet now carries the group's picture as its preview thumbnail, so what is being invited to is recognisable before it is sent. The image goes out as ClipData, which needs a content:// URI another process can open, so SharePreviewImage loads the rendition through the app's shared Coil loader — keyed on the durable blob id, so it hits what the screen already downloaded — and writes it into the share_previews cache the tip-code previews use. Best effort throughout: no picture, a slow fetch or an expired download URL all share what they shared before. The URL is resolved in the sheet rather than in the share controller, because re-minting an expired one needs the chat's profile to authorize it and the shareable module sees nothing of :services:flipcash. An untitled group reached the controller as "" rather than null — ChatSubject.Group.title is groupTitle.orEmpty() — so the invitation would have named a group with a hole where the name goes. Blank now falls back to the bare link the way null does, which is what iOS' GroupInviteShareItem does with the same value. --- .../app/core/share/SharePreviewImage.kt | 89 +++++++++++++++++++ .../flipcash/app/messenger/ChatFlowScreen.kt | 3 +- .../internal/screens/GroupInviteSheet.kt | 28 +++++- .../screens/profile/GroupProfileMenuItems.kt | 12 +-- .../app/shareable/ShareSheetController.kt | 10 ++- .../internal/InternalShareSheetController.kt | 31 +++++-- .../internal/ShareGroupInviteTest.kt | 15 ++++ 7 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/SharePreviewImage.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/SharePreviewImage.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/SharePreviewImage.kt new file mode 100644 index 0000000000..425319180b --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/SharePreviewImage.kt @@ -0,0 +1,89 @@ +package com.flipcash.app.core.share + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import coil3.SingletonImageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +import coil3.toBitmap +import kotlinx.coroutines.withTimeoutOrNull +import java.io.File +import java.security.MessageDigest +import kotlin.time.Duration.Companion.milliseconds + +/** + * Puts a remote image on disk as a `content://` URI the Sharesheet can read, so a share can carry a + * thumbnail of something the app only holds as a URL. + * + * The Sharesheet draws its preview from the intent's ClipData, and ClipData needs a URI a *different* + * process can open — a Coil-cached bitmap in this process is not one. So the image is loaded through + * the app's shared loader (cache-first, keyed on the durable blob id so it hits the rendition the + * screen already downloaded) and written into the same `share_previews` cache the tip-code previews + * use, which gets the FileProvider path and the pruning for free. + * + * Everything here is best-effort: a slow network, an expired download URL, or a full disk all return + * null, and a share with no thumbnail is the same share. + */ +object SharePreviewImage { + + /** + * Longest side to request. The Sharesheet's thumbnail slot is small, and the server ships a 320 + * rendition — asking for more downloads a bigger image to draw it at the same size. + */ + const val TARGET_PX = 320 + + /** How long a share is willing to wait on the image before going without one. */ + private const val FETCH_TIMEOUT_MS = 3_000L + + /** + * Caches the image at [url], returning the URI to hand to `ClipData`, or null if it could not be + * fetched or written. + * + * [cacheKey] is the rendition's stable identity ([com.flipcash.services.models.chat.MediaItem]'s + * `cacheKeyForSize`), used both to hit Coil's caches and to name the file — the download URL is + * re-minted on every fetch, so it names nothing stable. + */ + suspend fun cache(context: Context, url: String, cacheKey: String? = null): Uri? { + val file = File(dir(context), "${signature(cacheKey ?: url)}.png") + // Already written by an earlier share of the same rendition. Touch it so the pruner treats + // it as recently used rather than as the oldest thing in the directory. + if (file.exists() && file.length() > 0) { + file.setLastModified(System.currentTimeMillis()) + return uriFor(context, file) + } + + val bitmap = load(context, url, cacheKey) ?: return null + return runCatching { + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + uriFor(context, file) + }.getOrNull() + } + + private suspend fun load(context: Context, url: String, cacheKey: String?): Bitmap? = + withTimeoutOrNull(FETCH_TIMEOUT_MS.milliseconds) { + runCatching { + val request = ImageRequest.Builder(context) + .data(url) + .apply { cacheKey?.let { memoryCacheKey(it); diskCacheKey(it) } } + // compress() cannot read a hardware bitmap. + .allowHardware(false) + .build() + (SingletonImageLoader.get(context).execute(request) as? SuccessResult) + ?.image?.toBitmap() + }.getOrNull() + } + + /** Shares the tip-code previews' directory, and with it their FileProvider path and pruning. */ + private fun dir(context: Context): File = TipCodePreviewStorage.dir(context) + + private fun uriFor(context: Context, file: File): Uri = + TipCodePreviewStorage.uriFor(context, file) + + private fun signature(of: String): String = + MessageDigest.getInstance("SHA-256") + .digest(of.toByteArray()) + .take(8) + .joinToString("") { "%02x".format(it) } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index c97d103d1f..47af5c90da 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -18,6 +18,7 @@ import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatSendResult import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.extensions.openAsSheet +import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.GroupInviteSheet import com.flipcash.app.messenger.internal.screens.MessengerScreen @@ -233,7 +234,7 @@ private fun FlowGroupInviteSheet() { GroupInviteSheet( inviteUrl = state.groupInviteUrl, - groupTitle = state.subject?.title, + group = state.subject as? ChatSubject.Group, onCopy = { viewModel.dispatchEvent(ChatViewModel.Event.CopyInviteLink) }, onDismiss = dismissSheet, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 40305adcd8..5ac45c79a0 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -10,9 +10,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import com.flipcash.app.core.media.rememberMediaUrl +import com.flipcash.app.core.share.SharePreviewImage +import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.shareable.LocalShareController import com.flipcash.app.shareable.Shareable import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.BlobAccessContext import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle @@ -30,17 +34,31 @@ import kotlinx.coroutines.launch * @param inviteUrl null for anyone who has nobody to invite — a DM, or a group this viewer has not * joined — in which case the sheet is the title bar alone. Both entry points are gated on the same * value, so this is a race with a leave rather than a state to design for. + * @param group what is being invited to, for the group's name and picture. The sheet only opens on + * a group, so null is the frame before the subject resolves. */ @Composable internal fun GroupInviteSheet( inviteUrl: String?, - groupTitle: String?, + group: ChatSubject.Group?, onCopy: () -> Unit, onDismiss: () -> Unit, ) { val shareController = LocalShareController.current val scope = rememberCoroutineScope() + // Resolved here rather than in the share controller: the stored download URL expires, and + // re-minting it needs the chat's profile to authorize it — neither of which the shareable + // module can see. Same size and access as the avatar the conversation bar draws. + val picture = group?.picture + val pictureUrl = rememberMediaUrl( + media = picture, + targetLongestSidePx = SharePreviewImage.TARGET_PX, + access = group?.chatId + ?.let { BlobAccessContext.ChatProfile(it) } + ?: BlobAccessContext.Owned, + ) + Column( modifier = Modifier .fillMaxWidth() @@ -66,7 +84,13 @@ internal fun GroupInviteSheet( onClick = { scope.launch { shareController.present( - Shareable.GroupInvite(url = inviteUrl, title = groupTitle) + Shareable.GroupInvite( + url = inviteUrl, + title = group?.groupTitle, + imageUrl = pictureUrl.url, + imageCacheKey = picture + ?.cacheKeyForSize(SharePreviewImage.TARGET_PX), + ) ) } }, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt index c85dd45b2c..64059feb84 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt @@ -2,10 +2,10 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Logout +import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.flipcash.app.menu.FullMenuItem import com.flipcash.features.messenger.R @@ -28,14 +28,14 @@ internal sealed interface GroupProfileAction { * * Shown only while there is a link to hand out, which is the same condition as being a member. * - * The people glyph, not the `@` one: `ic_at` belongs to the row *inside* the sheet this opens, - * where it stands for the username the link is sent to (node 10127:118328). On the row that opens - * the sheet it said the wrong thing — what the row does is add people to the group, which is what - * the New Chat group row uses the same icon for. + * A person-with-plus glyph, matching iOS' `person.badge.plus` on the same row. Not the `@` one: + * `ic_at` belongs to the row *inside* the sheet this opens, where it stands for the username the + * link is sent to (node 10127:118328). Not `ic_group_3` either, which names a group rather than the + * act of adding to one — that is the New Chat row's job. */ internal data object InviteToGroup : FullMenuItem<GroupProfileAction>() { override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_group_3) + @Composable get() = rememberVectorPainter(Icons.Outlined.PersonAdd) override val name: String @Composable get() = stringResource(R.string.action_invitePeopleToJoin) diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt index fa9bfad696..3e79634a5a 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt @@ -54,8 +54,16 @@ sealed interface Shareable { */ data class GroupInvite( val url: String, - // The group's name, which the invitation sentence names. Null shares the bare link. + // The group's name, which the invitation sentence names. Null or blank shares the bare link. val title: String? = null, + /** + * The group's picture, as a download URL, for the Sharesheet's preview thumbnail. Null for + * a group with no picture, and best-effort even when set — see + * [com.flipcash.app.core.share.SharePreviewImage]. + */ + val imageUrl: String? = null, + /** The picture rendition's stable cache key, so the fetch hits what the screen downloaded. */ + val imageCacheKey: String? = null, ) : Shareable { override val pendingData: ShareablePendingData? = null } diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt index 83efcefd34..5fff116c22 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt @@ -12,6 +12,7 @@ import android.os.Build import androidx.core.net.toUri import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.flipcash.app.core.money.formatted +import com.flipcash.app.core.share.SharePreviewImage import com.flipcash.app.core.util.Linkify import com.flipcash.app.core.util.MessagingPackages import com.flipcash.app.core.tipping.TipCardOwner @@ -297,11 +298,21 @@ internal class InternalShareSheetController( context.startActivity(share) } - private fun shareGroupInvite(shareable: Shareable.GroupInvite) { - // The invitation names the group, so with no title there is nothing to say — share the bare - // link rather than an invitation with a hole in it. Same wording as iOS' GroupInviteSheet. + private suspend fun shareGroupInvite(shareable: Shareable.GroupInvite) { + // The invitation names the group, so with no name there is nothing to say — share the bare + // link rather than an invitation with a hole in it. Blank counts as no name: an untitled + // group reaches this as "" via ChatSubject.Group.title. Same wording as iOS' + // GroupInviteShareItem, which trims and checks the same way. val invitation = shareable.title - ?.let { resources.getString(R.string.message_groupInvite, it) } + ?.takeIf { it.isNotBlank() } + ?.let { resources.getString(R.string.message_groupInvite, it.trim()) } + + // The group's own picture as the Sharesheet's thumbnail, so the invite is recognisable + // before it is sent. Best-effort: no picture, a slow fetch or an expired URL all share + // exactly what they shared before. + val preview = shareable.imageUrl?.let { + SharePreviewImage.cache(context, it, shareable.imageCacheKey) + } val intent = Intent().apply { action = Intent.ACTION_SEND @@ -314,10 +325,20 @@ internal class InternalShareSheetController( if (invitation != null) "$invitation\n\n${shareable.url}" else shareable.url ) type = "text/plain" + + if (preview != null) { + // The Sharesheet draws its thumbnail from ClipData, not from any extra — see + // shareTipCard. The payload stays the text; this is preview only. + clipData = ClipData.newUri(context.contentResolver, "Group picture", preview) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } } val share = Intent.createChooser(intent, null).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK + // addFlags, not `flags =` — assigning would wipe the read grant createChooser migrates + // onto the chooser intent, and the Sharesheet could not open the image. See shareTipCard. + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (preview != null) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } context.startActivity(share) diff --git a/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt b/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt index d16d603f6d..80af20fa88 100644 --- a/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt +++ b/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt @@ -55,6 +55,21 @@ class ShareGroupInviteTest { ) } + /** + * An untitled group reaches the controller as "" rather than null — `ChatSubject.Group.title` + * is `groupTitle.orEmpty()` — so blank has to fall back the same way null does, or the + * invitation goes out with a hole where the name should be. + */ + @Test + fun `blank title shares the bare link`() = runTest { + val sent = present( + Shareable.GroupInvite(url = "https://app.flipcash.com/chat/abc", title = " ") + ) + + assertEquals("https://app.flipcash.com/chat/abc", sent.getStringExtra(Intent.EXTRA_TEXT)) + assertNull(sent.getStringExtra(Intent.EXTRA_TITLE)) + } + @Test fun `untitled group shares the bare link`() = runTest { val sent = present( From 7dd4366b5280fdbe28fe1dd830abdf72fd99cdbe Mon Sep 17 00:00:00 2001 From: Brandon McAnsh <git@bmcreations.dev> Date: Fri, 18 Sep 2026 15:53:55 -0400 Subject: [PATCH 3/3] fix(shareable): headline the group's name, not the invitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EXTRA_TITLE carried the invitation sentence, so the Sharesheet drew it as the headline directly above the same sentence as the first line of the body — and truncated it there, the headline being a single line. The group's name is what that slot is for. EXTRA_SUBJECT keeps the sentence. It is an email's subject line rather than a headline sitting above a body, so there is nothing to repeat. --- .../app/shareable/ShareSheetController.kt | 3 ++- .../internal/InternalShareSheetController.kt | 17 ++++++++++------- .../shareable/internal/ShareGroupInviteTest.kt | 7 +++---- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt index 3e79634a5a..d8f1719e39 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt @@ -54,7 +54,8 @@ sealed interface Shareable { */ data class GroupInvite( val url: String, - // The group's name, which the invitation sentence names. Null or blank shares the bare link. + // The group's name: the Sharesheet's headline, and what the invitation sentence names. + // Null or blank shares the bare link, with no headline. val title: String? = null, /** * The group's picture, as a download URL, for the Sharesheet's preview thumbnail. Null for diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt index 5fff116c22..c434ff02d7 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt @@ -303,9 +303,8 @@ internal class InternalShareSheetController( // link rather than an invitation with a hole in it. Blank counts as no name: an untitled // group reaches this as "" via ChatSubject.Group.title. Same wording as iOS' // GroupInviteShareItem, which trims and checks the same way. - val invitation = shareable.title - ?.takeIf { it.isNotBlank() } - ?.let { resources.getString(R.string.message_groupInvite, it.trim()) } + val name = shareable.title?.takeIf { it.isNotBlank() }?.trim() + val invitation = name?.let { resources.getString(R.string.message_groupInvite, it) } // The group's own picture as the Sharesheet's thumbnail, so the invite is recognisable // before it is sent. Best-effort: no picture, a slow fetch or an expired URL all share @@ -316,10 +315,14 @@ internal class InternalShareSheetController( val intent = Intent().apply { action = Intent.ACTION_SEND - invitation?.let { - putExtra(Intent.EXTRA_TITLE, it) - putExtra(Intent.EXTRA_SUBJECT, it) - } + // The headline above the Sharesheet's preview. The group's name, not the invitation: + // the sentence is already the first line of the body directly beneath it, and the + // headline is one truncated line, so putting it in both says the name twice and cuts + // it off once. + name?.let { putExtra(Intent.EXTRA_TITLE, it) } + // EXTRA_SUBJECT is an email's subject line rather than a preview headline, so the + // invitation belongs here — it reads as the message it is, with no body beside it. + invitation?.let { putExtra(Intent.EXTRA_SUBJECT, it) } putExtra( Intent.EXTRA_TEXT, if (invitation != null) "$invitation\n\n${shareable.url}" else shareable.url diff --git a/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt b/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt index 80af20fa88..2f0f4e6bde 100644 --- a/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt +++ b/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt @@ -45,10 +45,9 @@ class ShareGroupInviteTest { "Join Book Club on Flipcash and let's chat\n\nhttps://app.flipcash.com/chat/abc", sent.getStringExtra(Intent.EXTRA_TEXT) ) - assertEquals( - "Join Book Club on Flipcash and let's chat", - sent.getStringExtra(Intent.EXTRA_TITLE) - ) + // The headline names the group; repeating the sentence there would show it twice, once + // truncated. The subject is an email's, where the sentence is the message. + assertEquals("Book Club", sent.getStringExtra(Intent.EXTRA_TITLE)) assertEquals( "Join Book Club on Flipcash and let's chat", sent.getStringExtra(Intent.EXTRA_SUBJECT)