Skip to content

Commit 6edcf08

Browse files
committed
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.
1 parent ad6bb4d commit 6edcf08

7 files changed

Lines changed: 173 additions & 15 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/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/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt‎

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

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

Lines changed: 26 additions & 5 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,11 +298,21 @@ internal class InternalShareSheetController(
297298
context.startActivity(share)
298299
}
299300

300-
private fun shareGroupInvite(shareable: Shareable.GroupInvite) {
301-
// The invitation names the group, so with no title there is nothing to say — share the bare
302-
// link rather than an invitation with a hole in it. Same wording as iOS' GroupInviteSheet.
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.
303306
val invitation = shareable.title
304-
?.let { resources.getString(R.string.message_groupInvite, it) }
307+
?.takeIf { it.isNotBlank() }
308+
?.let { resources.getString(R.string.message_groupInvite, it.trim()) }
309+
310+
// The group's own picture as the Sharesheet's thumbnail, so the invite is recognisable
311+
// before it is sent. Best-effort: no picture, a slow fetch or an expired URL all share
312+
// exactly what they shared before.
313+
val preview = shareable.imageUrl?.let {
314+
SharePreviewImage.cache(context, it, shareable.imageCacheKey)
315+
}
305316

306317
val intent = Intent().apply {
307318
action = Intent.ACTION_SEND
@@ -314,10 +325,20 @@ internal class InternalShareSheetController(
314325
if (invitation != null) "$invitation\n\n${shareable.url}" else shareable.url
315326
)
316327
type = "text/plain"
328+
329+
if (preview != null) {
330+
// The Sharesheet draws its thumbnail from ClipData, not from any extra — see
331+
// shareTipCard. The payload stays the text; this is preview only.
332+
clipData = ClipData.newUri(context.contentResolver, "Group picture", preview)
333+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
334+
}
317335
}
318336

319337
val share = Intent.createChooser(intent, null).apply {
320-
flags = Intent.FLAG_ACTIVITY_NEW_TASK
338+
// addFlags, not `flags =` — assigning would wipe the read grant createChooser migrates
339+
// onto the chooser intent, and the Sharesheet could not open the image. See shareTipCard.
340+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
341+
if (preview != null) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
321342
}
322343

323344
context.startActivity(share)

‎apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareGroupInviteTest.kt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ class ShareGroupInviteTest {
5555
)
5656
}
5757

58+
/**
59+
* An untitled group reaches the controller as "" rather than null — `ChatSubject.Group.title`
60+
* is `groupTitle.orEmpty()` — so blank has to fall back the same way null does, or the
61+
* invitation goes out with a hole where the name should be.
62+
*/
63+
@Test
64+
fun `blank title shares the bare link`() = runTest {
65+
val sent = present(
66+
Shareable.GroupInvite(url = "https://app.flipcash.com/chat/abc", title = " ")
67+
)
68+
69+
assertEquals("https://app.flipcash.com/chat/abc", sent.getStringExtra(Intent.EXTRA_TEXT))
70+
assertNull(sent.getStringExtra(Intent.EXTRA_TITLE))
71+
}
72+
5873
@Test
5974
fun `untitled group shares the bare link`() = runTest {
6075
val sent = present(

0 commit comments

Comments
 (0)