Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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) }
}
3 changes: 2 additions & 1 deletion apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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),
)
)
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions apps/flipcash/shared/shareable/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading