Skip to content

Commit 3de15ea

Browse files
authored
feat(messenger): send an invitation with the group invite link (#1500)
* feat(shareable): send an invitation with the group invite link 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 <title> 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. * feat(messenger): match iOS' invite icon and preview the group's picture 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. * fix(shareable): headline the group's name, not the invitation 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.
1 parent 83ad1c5 commit 3de15ea

9 files changed

Lines changed: 268 additions & 19 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.flipcash.app.core.share
2+
3+
import android.content.Context
4+
import android.graphics.Bitmap
5+
import android.net.Uri
6+
import coil3.SingletonImageLoader
7+
import coil3.request.ImageRequest
8+
import coil3.request.SuccessResult
9+
import coil3.request.allowHardware
10+
import coil3.toBitmap
11+
import kotlinx.coroutines.withTimeoutOrNull
12+
import java.io.File
13+
import java.security.MessageDigest
14+
import kotlin.time.Duration.Companion.milliseconds
15+
16+
/**
17+
* Puts a remote image on disk as a `content://` URI the Sharesheet can read, so a share can carry a
18+
* thumbnail of something the app only holds as a URL.
19+
*
20+
* The Sharesheet draws its preview from the intent's ClipData, and ClipData needs a URI a *different*
21+
* process can open — a Coil-cached bitmap in this process is not one. So the image is loaded through
22+
* the app's shared loader (cache-first, keyed on the durable blob id so it hits the rendition the
23+
* screen already downloaded) and written into the same `share_previews` cache the tip-code previews
24+
* use, which gets the FileProvider path and the pruning for free.
25+
*
26+
* Everything here is best-effort: a slow network, an expired download URL, or a full disk all return
27+
* null, and a share with no thumbnail is the same share.
28+
*/
29+
object SharePreviewImage {
30+
31+
/**
32+
* Longest side to request. The Sharesheet's thumbnail slot is small, and the server ships a 320
33+
* rendition — asking for more downloads a bigger image to draw it at the same size.
34+
*/
35+
const val TARGET_PX = 320
36+
37+
/** How long a share is willing to wait on the image before going without one. */
38+
private const val FETCH_TIMEOUT_MS = 3_000L
39+
40+
/**
41+
* Caches the image at [url], returning the URI to hand to `ClipData`, or null if it could not be
42+
* fetched or written.
43+
*
44+
* [cacheKey] is the rendition's stable identity ([com.flipcash.services.models.chat.MediaItem]'s
45+
* `cacheKeyForSize`), used both to hit Coil's caches and to name the file — the download URL is
46+
* re-minted on every fetch, so it names nothing stable.
47+
*/
48+
suspend fun cache(context: Context, url: String, cacheKey: String? = null): Uri? {
49+
val file = File(dir(context), "${signature(cacheKey ?: url)}.png")
50+
// Already written by an earlier share of the same rendition. Touch it so the pruner treats
51+
// it as recently used rather than as the oldest thing in the directory.
52+
if (file.exists() && file.length() > 0) {
53+
file.setLastModified(System.currentTimeMillis())
54+
return uriFor(context, file)
55+
}
56+
57+
val bitmap = load(context, url, cacheKey) ?: return null
58+
return runCatching {
59+
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
60+
uriFor(context, file)
61+
}.getOrNull()
62+
}
63+
64+
private suspend fun load(context: Context, url: String, cacheKey: String?): Bitmap? =
65+
withTimeoutOrNull(FETCH_TIMEOUT_MS.milliseconds) {
66+
runCatching {
67+
val request = ImageRequest.Builder(context)
68+
.data(url)
69+
.apply { cacheKey?.let { memoryCacheKey(it); diskCacheKey(it) } }
70+
// compress() cannot read a hardware bitmap.
71+
.allowHardware(false)
72+
.build()
73+
(SingletonImageLoader.get(context).execute(request) as? SuccessResult)
74+
?.image?.toBitmap()
75+
}.getOrNull()
76+
}
77+
78+
/** Shares the tip-code previews' directory, and with it their FileProvider path and pruning. */
79+
private fun dir(context: Context): File = TipCodePreviewStorage.dir(context)
80+
81+
private fun uriFor(context: Context, file: File): Uri =
82+
TipCodePreviewStorage.uriFor(context, file)
83+
84+
private fun signature(of: String): String =
85+
MessageDigest.getInstance("SHA-256")
86+
.digest(of.toByteArray())
87+
.take(8)
88+
.joinToString("") { "%02x".format(it) }
89+
}

‎apps/flipcash/core/src/main/res/values/strings.xml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1019,7 +1019,8 @@
10191019
<string name="action_sendInviteLink">Send Invite Link</string>
10201020
<string name="action_copyInviteLink">Copy Invite Link</string>
10211021
<string name="action_invitePeopleToJoin">Invite People To Join</string>
1022-
<string name="subtitle_shareGroupInvite">Join me on Flipcash</string>
1022+
<!-- Sent ahead of the invite link in the share sheet; %1$s is the group title. Same wording as iOS. -->
1023+
<string name="message_groupInvite">Join %1$s on Flipcash and let\'s chat</string>
10231024
<string name="title_tipIntro">Receive Tips From Everyone</string>
10241025
<string name="subtitle_tipIntro">Add your name to receive tips</string>
10251026
<string name="action_startReceivingTips">Start Receiving Tips</string>

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.flipcash.app.core.chat.ChatParticipant
1818
import com.flipcash.app.core.chat.ChatSendResult
1919
import com.flipcash.app.core.chat.ChatStep
2020
import com.flipcash.app.core.extensions.openAsSheet
21+
import com.flipcash.app.messenger.internal.ChatSubject
2122
import com.flipcash.app.messenger.internal.ChatViewModel
2223
import com.flipcash.app.messenger.internal.screens.GroupInviteSheet
2324
import com.flipcash.app.messenger.internal.screens.MessengerScreen
@@ -233,7 +234,7 @@ private fun FlowGroupInviteSheet() {
233234

234235
GroupInviteSheet(
235236
inviteUrl = state.groupInviteUrl,
236-
groupTitle = state.subject?.title,
237+
group = state.subject as? ChatSubject.Group,
237238
onCopy = { viewModel.dispatchEvent(ChatViewModel.Event.CopyInviteLink) },
238239
onDismiss = dismissSheet,
239240
)

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ import androidx.compose.runtime.rememberCoroutineScope
1010
import androidx.compose.ui.Alignment
1111
import androidx.compose.ui.Modifier
1212
import androidx.compose.ui.res.stringResource
13+
import com.flipcash.app.core.media.rememberMediaUrl
14+
import com.flipcash.app.core.share.SharePreviewImage
15+
import com.flipcash.app.messenger.internal.ChatSubject
1316
import com.flipcash.app.shareable.LocalShareController
1417
import com.flipcash.app.shareable.Shareable
1518
import com.flipcash.features.messenger.R
19+
import com.flipcash.services.models.chat.BlobAccessContext
1620
import com.getcode.theme.CodeTheme
1721
import com.getcode.ui.components.AppBarDefaults
1822
import com.getcode.ui.components.AppBarWithTitle
@@ -30,17 +34,31 @@ import kotlinx.coroutines.launch
3034
* @param inviteUrl null for anyone who has nobody to invite — a DM, or a group this viewer has not
3135
* joined — in which case the sheet is the title bar alone. Both entry points are gated on the same
3236
* value, so this is a race with a leave rather than a state to design for.
37+
* @param group what is being invited to, for the group's name and picture. The sheet only opens on
38+
* a group, so null is the frame before the subject resolves.
3339
*/
3440
@Composable
3541
internal fun GroupInviteSheet(
3642
inviteUrl: String?,
37-
groupTitle: String?,
43+
group: ChatSubject.Group?,
3844
onCopy: () -> Unit,
3945
onDismiss: () -> Unit,
4046
) {
4147
val shareController = LocalShareController.current
4248
val scope = rememberCoroutineScope()
4349

50+
// Resolved here rather than in the share controller: the stored download URL expires, and
51+
// re-minting it needs the chat's profile to authorize it — neither of which the shareable
52+
// module can see. Same size and access as the avatar the conversation bar draws.
53+
val picture = group?.picture
54+
val pictureUrl = rememberMediaUrl(
55+
media = picture,
56+
targetLongestSidePx = SharePreviewImage.TARGET_PX,
57+
access = group?.chatId
58+
?.let { BlobAccessContext.ChatProfile(it) }
59+
?: BlobAccessContext.Owned,
60+
)
61+
4462
Column(
4563
modifier = Modifier
4664
.fillMaxWidth()
@@ -66,7 +84,13 @@ internal fun GroupInviteSheet(
6684
onClick = {
6785
scope.launch {
6886
shareController.present(
69-
Shareable.GroupInvite(url = inviteUrl, title = groupTitle)
87+
Shareable.GroupInvite(
88+
url = inviteUrl,
89+
title = group?.groupTitle,
90+
imageUrl = pictureUrl.url,
91+
imageCacheKey = picture
92+
?.cacheKeyForSize(SharePreviewImage.TARGET_PX),
93+
)
7094
)
7195
}
7296
},

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ package com.flipcash.app.messenger.internal.screens.profile
22

33
import androidx.compose.material.icons.Icons
44
import androidx.compose.material.icons.automirrored.outlined.Logout
5+
import androidx.compose.material.icons.outlined.PersonAdd
56
import androidx.compose.runtime.Composable
67
import androidx.compose.ui.graphics.painter.Painter
78
import androidx.compose.ui.graphics.vector.rememberVectorPainter
8-
import androidx.compose.ui.res.painterResource
99
import androidx.compose.ui.res.stringResource
1010
import com.flipcash.app.menu.FullMenuItem
1111
import com.flipcash.features.messenger.R
@@ -28,14 +28,14 @@ internal sealed interface GroupProfileAction {
2828
*
2929
* Shown only while there is a link to hand out, which is the same condition as being a member.
3030
*
31-
* The people glyph, not the `@` one: `ic_at` belongs to the row *inside* the sheet this opens,
32-
* where it stands for the username the link is sent to (node 10127:118328). On the row that opens
33-
* the sheet it said the wrong thing — what the row does is add people to the group, which is what
34-
* the New Chat group row uses the same icon for.
31+
* A person-with-plus glyph, matching iOS' `person.badge.plus` on the same row. Not the `@` one:
32+
* `ic_at` belongs to the row *inside* the sheet this opens, where it stands for the username the
33+
* link is sent to (node 10127:118328). Not `ic_group_3` either, which names a group rather than the
34+
* act of adding to one — that is the New Chat row's job.
3535
*/
3636
internal data object InviteToGroup : FullMenuItem<GroupProfileAction>() {
3737
override val icon: Painter
38-
@Composable get() = painterResource(R.drawable.ic_group_3)
38+
@Composable get() = rememberVectorPainter(Icons.Outlined.PersonAdd)
3939

4040
override val name: String
4141
@Composable get() = stringResource(R.string.action_invitePeopleToJoin)

‎apps/flipcash/shared/shareable/build.gradle.kts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ android {
77
}
88

99
dependencies {
10+
testImplementation(kotlin("test"))
11+
testImplementation(libs.bundles.unit.testing)
12+
testImplementation(libs.robolectric)
13+
testImplementation(testFixtures(project(":ui:resources")))
14+
1015
implementation(libs.bundles.kotlinx.serialization)
1116

1217
implementation(libs.androidx.localbroadcastmanager)

‎apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,17 @@ sealed interface Shareable {
5454
*/
5555
data class GroupInvite(
5656
val url: String,
57-
// The group's title, for the Sharesheet heading. Null shares the link with no heading.
57+
// The group's name: the Sharesheet's headline, and what the invitation sentence names.
58+
// Null or blank shares the bare link, with no headline.
5859
val title: String? = null,
60+
/**
61+
* The group's picture, as a download URL, for the Sharesheet's preview thumbnail. Null for
62+
* a group with no picture, and best-effort even when set — see
63+
* [com.flipcash.app.core.share.SharePreviewImage].
64+
*/
65+
val imageUrl: String? = null,
66+
/** The picture rendition's stable cache key, so the fetch hits what the screen downloaded. */
67+
val imageCacheKey: String? = null,
5968
) : Shareable {
6069
override val pendingData: ShareablePendingData? = null
6170
}

‎apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt‎

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import android.os.Build
1212
import androidx.core.net.toUri
1313
import androidx.localbroadcastmanager.content.LocalBroadcastManager
1414
import com.flipcash.app.core.money.formatted
15+
import com.flipcash.app.core.share.SharePreviewImage
1516
import com.flipcash.app.core.util.Linkify
1617
import com.flipcash.app.core.util.MessagingPackages
1718
import com.flipcash.app.core.tipping.TipCardOwner
@@ -297,20 +298,50 @@ internal class InternalShareSheetController(
297298
context.startActivity(share)
298299
}
299300

300-
private fun shareGroupInvite(shareable: Shareable.GroupInvite) {
301+
private suspend fun shareGroupInvite(shareable: Shareable.GroupInvite) {
302+
// The invitation names the group, so with no name there is nothing to say — share the bare
303+
// link rather than an invitation with a hole in it. Blank counts as no name: an untitled
304+
// group reaches this as "" via ChatSubject.Group.title. Same wording as iOS'
305+
// GroupInviteShareItem, which trims and checks the same way.
306+
val name = shareable.title?.takeIf { it.isNotBlank() }?.trim()
307+
val invitation = name?.let { resources.getString(R.string.message_groupInvite, it) }
308+
309+
// The group's own picture as the Sharesheet's thumbnail, so the invite is recognisable
310+
// before it is sent. Best-effort: no picture, a slow fetch or an expired URL all share
311+
// exactly what they shared before.
312+
val preview = shareable.imageUrl?.let {
313+
SharePreviewImage.cache(context, it, shareable.imageCacheKey)
314+
}
315+
301316
val intent = Intent().apply {
302317
action = Intent.ACTION_SEND
303-
// No heading when the group has no title yet — an empty one reads as a broken share.
304-
shareable.title?.let {
305-
putExtra(Intent.EXTRA_TITLE, it)
306-
putExtra(Intent.EXTRA_SUBJECT, it)
307-
}
308-
putExtra(Intent.EXTRA_TEXT, shareable.url)
318+
// The headline above the Sharesheet's preview. The group's name, not the invitation:
319+
// the sentence is already the first line of the body directly beneath it, and the
320+
// headline is one truncated line, so putting it in both says the name twice and cuts
321+
// it off once.
322+
name?.let { putExtra(Intent.EXTRA_TITLE, it) }
323+
// EXTRA_SUBJECT is an email's subject line rather than a preview headline, so the
324+
// invitation belongs here — it reads as the message it is, with no body beside it.
325+
invitation?.let { putExtra(Intent.EXTRA_SUBJECT, it) }
326+
putExtra(
327+
Intent.EXTRA_TEXT,
328+
if (invitation != null) "$invitation\n\n${shareable.url}" else shareable.url
329+
)
309330
type = "text/plain"
331+
332+
if (preview != null) {
333+
// The Sharesheet draws its thumbnail from ClipData, not from any extra — see
334+
// shareTipCard. The payload stays the text; this is preview only.
335+
clipData = ClipData.newUri(context.contentResolver, "Group picture", preview)
336+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
337+
}
310338
}
311339

312340
val share = Intent.createChooser(intent, null).apply {
313-
flags = Intent.FLAG_ACTIVITY_NEW_TASK
341+
// addFlags, not `flags =` — assigning would wipe the read grant createChooser migrates
342+
// onto the chooser intent, and the Sharesheet could not open the image. See shareTipCard.
343+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
344+
if (preview != null) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
314345
}
315346

316347
context.startActivity(share)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.flipcash.app.shareable.internal
2+
3+
import android.app.Application
4+
import android.content.ClipboardManager
5+
import android.content.Context
6+
import android.content.Intent
7+
import androidx.test.core.app.ApplicationProvider
8+
import com.flipcash.app.shareable.Shareable
9+
import com.flipcash.shared.shareable.R
10+
import com.getcode.util.resources.FakeResourceHelper
11+
import kotlinx.coroutines.test.runTest
12+
import org.junit.runner.RunWith
13+
import org.robolectric.RobolectricTestRunner
14+
import org.robolectric.Shadows.shadowOf
15+
import kotlin.test.Test
16+
import kotlin.test.assertEquals
17+
import kotlin.test.assertNull
18+
19+
/**
20+
* The shared text is what the recipient reads, so it is asserted verbatim — the blank line between
21+
* the invitation and the link is what keeps the URL unfurling on its own instead of running into
22+
* the sentence. iOS' GroupInviteSheet sends the same two lines.
23+
*/
24+
@RunWith(RobolectricTestRunner::class)
25+
class ShareGroupInviteTest {
26+
27+
private val application: Application = ApplicationProvider.getApplicationContext()
28+
29+
private val resources = FakeResourceHelper()
30+
.stub(R.string.message_groupInvite, "Join %1\$s on Flipcash and let's chat")
31+
32+
private val controller = InternalShareSheetController(
33+
context = application,
34+
clipboardManager = application.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager,
35+
resources = resources,
36+
)
37+
38+
@Test
39+
fun `titled group shares the invitation above the link`() = runTest {
40+
val sent = present(
41+
Shareable.GroupInvite(url = "https://app.flipcash.com/chat/abc", title = "Book Club")
42+
)
43+
44+
assertEquals(
45+
"Join Book Club on Flipcash and let's chat\n\nhttps://app.flipcash.com/chat/abc",
46+
sent.getStringExtra(Intent.EXTRA_TEXT)
47+
)
48+
// The headline names the group; repeating the sentence there would show it twice, once
49+
// truncated. The subject is an email's, where the sentence is the message.
50+
assertEquals("Book Club", sent.getStringExtra(Intent.EXTRA_TITLE))
51+
assertEquals(
52+
"Join Book Club on Flipcash and let's chat",
53+
sent.getStringExtra(Intent.EXTRA_SUBJECT)
54+
)
55+
}
56+
57+
/**
58+
* An untitled group reaches the controller as "" rather than null — `ChatSubject.Group.title`
59+
* is `groupTitle.orEmpty()` — so blank has to fall back the same way null does, or the
60+
* invitation goes out with a hole where the name should be.
61+
*/
62+
@Test
63+
fun `blank title shares the bare link`() = runTest {
64+
val sent = present(
65+
Shareable.GroupInvite(url = "https://app.flipcash.com/chat/abc", title = " ")
66+
)
67+
68+
assertEquals("https://app.flipcash.com/chat/abc", sent.getStringExtra(Intent.EXTRA_TEXT))
69+
assertNull(sent.getStringExtra(Intent.EXTRA_TITLE))
70+
}
71+
72+
@Test
73+
fun `untitled group shares the bare link`() = runTest {
74+
val sent = present(
75+
Shareable.GroupInvite(url = "https://app.flipcash.com/chat/abc", title = null)
76+
)
77+
78+
assertEquals("https://app.flipcash.com/chat/abc", sent.getStringExtra(Intent.EXTRA_TEXT))
79+
assertNull(sent.getStringExtra(Intent.EXTRA_TITLE))
80+
assertNull(sent.getStringExtra(Intent.EXTRA_SUBJECT))
81+
}
82+
83+
/** Presents [shareable] and returns the intent the chooser was built around. */
84+
private suspend fun present(shareable: Shareable): Intent {
85+
controller.present(shareable)
86+
val chooser = shadowOf(application).nextStartedActivity
87+
return requireNotNull(chooser.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java))
88+
}
89+
}

0 commit comments

Comments
 (0)