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/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 @@
Send Invite Link
Copy Invite Link
Invite People To Join
- Join me on Flipcash
+
+ Join %1$s on Flipcash and let\'s chat
Receive Tips From Everyone
Add your name to receive tips
Start Receiving Tips
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() {
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/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..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,8 +54,17 @@ 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: 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
+ * 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 e545f1b25e..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
@@ -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,20 +298,50 @@ internal class InternalShareSheetController(
context.startActivity(share)
}
- private fun shareGroupInvite(shareable: Shareable.GroupInvite) {
+ 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 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
+ // exactly what they shared before.
+ val preview = shareable.imageUrl?.let {
+ SharePreviewImage.cache(context, it, shareable.imageCacheKey)
+ }
+
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 {
- putExtra(Intent.EXTRA_TITLE, it)
- putExtra(Intent.EXTRA_SUBJECT, it)
- }
- putExtra(Intent.EXTRA_TEXT, shareable.url)
+ // 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
+ )
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
new file mode 100644
index 0000000000..2f0f4e6bde
--- /dev/null
+++ b/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt
@@ -0,0 +1,89 @@
+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)
+ )
+ // 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)
+ )
+ }
+
+ /**
+ * 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(
+ 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))
+ }
+}