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)) + } +}