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,38 @@
package com.flipcash.app.core.route

import com.flipcash.app.core.AppRoute
import com.flipcash.services.models.chat.ChatId
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse

/**
* The group card's route is saved with the back stack, and an invite link's fragment is the chat
* key's secret, so the link must reach the card some other way (spec group-invites.md surface 1).
*/
class GroupCardRouteTest {
private val route = AppRoute.Menu.GroupCard(
chatId = ChatId("aa"),
title = "Moony",
picture = null,
memberCount = 48,
)

@Test
fun `the saved route has no field a link could ride in`() {
val saved = Json.encodeToString(AppRoute.Menu.GroupCard.serializer(), route)

assertEquals(setOf("chatId", "title", "picture", "memberCount"), Json.parseToJsonElement(saved).jsonObject.keys)
}

@Test
fun `the saved route holds no link`() {
val saved = Json.encodeToString(AppRoute.serializer(), route)

assertFalse("http" in saved)
assertFalse("#s=" in saved)
assertFalse("flipcash.com" in saved)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ class GroupCardQrTest {
assertEquals(caseOneUrl, decode(bitmap))
}

// 32 random bytes as unpadded base64url: the longest link a card encodes (spec Open 5).
private val inviteUrl = "https://app.flipcash.com/chat/invite#s=kQ3vR8nYw2pLx7TzA0bC9dEfGhIjKlMnOpQrStUxT9a"

@Test
fun `3b - an invite link fits version 6 and the styled qr with its logo decodes to it`() {
assertEquals(82, inviteUrl.length)
val matrix = QrMatrix.encodeGroupCard(inviteUrl)
assertEquals(41, matrix.size)
assertEquals(inviteUrl, decode(StyledQrBitmap.render(matrix)))
}

@Test
fun `4 - the qr is error correction Q, version 6, 41 modules`() {
// encodeGroupCard checks level and version itself; this proves it ran and the size is 41.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flipcash.app.core.share.groupcard

import com.google.zxing.EncodeHintType
import com.google.zxing.WriterException
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.qrcode.encoder.Encoder

Expand All @@ -15,28 +16,36 @@ class QrMatrix(val size: Int, private val bits: BooleanArray) {
x in 0 until size && y in 0 until size && bits[y * size + x]

companion object {
/** Version 6 is 41 modules: the smallest that holds the 66-byte invite URL at Q. */
/** Version 6 is 41 modules: the smallest that holds the 66-byte plain group URL at Q. */
const val GroupCardVersion = 6
const val GroupCardModules = 41

/**
* [text] as a byte-mode, error-correction-Q, version-6 symbol. Throws when it does not fit
* version 6 (ZXing raises a WriterException), rather than silently moving to a version that
* puts an alignment pattern under the logo.
* [text] as a byte-mode, version-6 symbol: at error correction Q when it fits, otherwise M.
* An invite link (`/chat/invite#s=…`, 82 bytes) does not fit version 6 at Q, and every
* larger version puts an alignment pattern under the logo, so it drops to M instead.
* Throws when [text] does not fit version 6 at M either (ZXing raises a WriterException).
*/
fun encodeGroupCard(text: String): QrMatrix {
val code = Encoder.encode(
text,
ErrorCorrectionLevel.Q,
mapOf(EncodeHintType.QR_VERSION to GroupCardVersion),
)
check(code.ecLevel == ErrorCorrectionLevel.Q) { "Expected error correction Q, got ${code.ecLevel}" }
val code = try {
encode(text, ErrorCorrectionLevel.Q)
} catch (_: WriterException) {
encode(text, ErrorCorrectionLevel.M)
}
check(code.version.versionNumber == GroupCardVersion) { "Expected version 6, got ${code.version.versionNumber}" }
val m = code.matrix
check(m.width == GroupCardModules && m.height == GroupCardModules) { "Expected 41 modules, got ${m.width}" }
val bits = BooleanArray(m.width * m.height)
for (y in 0 until m.height) for (x in 0 until m.width) bits[y * m.width + x] = m[x, y].toInt() == 1
return QrMatrix(m.width, bits)
}

private fun encode(text: String, level: ErrorCorrectionLevel) = Encoder.encode(
text,
level,
mapOf(EncodeHintType.QR_VERSION to GroupCardVersion),
).also { code ->
check(code.ecLevel == level) { "Expected error correction $level, got ${code.ecLevel}" }
}
}
}
18 changes: 13 additions & 5 deletions apps/flipcash/core/src/androidMain/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1583,12 +1583,16 @@
<!-- %1$s is the group title. -->
<string name="title_inviteToGroup">Invite to %1$s</string>
<string name="action_shareInviteLinkLong">Share Invite Link</string>
<string name="subtitle_shareInviteLink">Anyone with this link joins right away</string>
<string name="action_shareJoinLink">Share Join Link</string>
<string name="subtitle_shareJoinLink">People ask to join and you approve each one</string>
<string name="action_manageInvites">Manage Invites</string>
<string name="subtitle_manageInvites">See and revoke the invite links you\'ve made</string>
<string name="label_creatingInviteLink">Creating link…</string>
<!-- The host sheet's link card and rows (spec group-invites.md surface 1, frame 1m). -->
<!-- The card's caption, joined with " · ": how many joined, then when the link ends. -->
<string name="label_inviteJoinedThenEnds">%1$s · %2$s</string>
<string name="label_joinLinkAsksToJoin">People who open this link ask to join</string>
<string name="label_approveNewMembers">Approve New Members</string>
<string name="subtitle_approveNewMembersOff">Anyone with the link joins right away</string>
<string name="subtitle_approveNewMembersOn">You approve each person</string>
<string name="subtitle_manageInvitesRow">Make more links with an expiry or a use limit</string>
<string name="action_shareLink">Share Link</string>
<string name="footnote_inviteLink">Anyone with this link can join and see who\'s in the group. Share it only with people you want in the group</string>
<!-- A link's lines, joined with " · ". %1$s is a date such as "Nov 3". -->
Expand Down Expand Up @@ -1848,10 +1852,14 @@
<string name="label_profileCardFileNameFallback">Profile Card</string>
<string name="error_title_tipCardExportFailed">Couldn\'t Export</string>
<string name="error_description_tipCardExportFailed">We were unable to export your tip code. Please try again</string>
<string name="action_showGroupCard">Show Group Card</string>
<string name="action_showGroupCard">Show QR Code</string>
<string name="label_groupCardTitleFallback">Group Chat</string>
<string name="label_groupCardFileName">Join %1$s</string>
<string name="label_groupCardFileNameFallback">Group Card</string>
<string name="label_groupCardScanToJoin">Scan to join</string>
<string name="label_groupCardLinkExpires">Link expires %1$s</string>
<string name="label_groupCardLinkNeverExpires">Link never expires</string>
<string name="footnote_groupCardInviteLink">Anyone who scans this card joins without approval until the link expires or you revoke it.</string>
<string name="error_description_groupCardExportFailed">We were unable to export your group code. Please try again</string>

<!-- Bio editor. The count under the field is code points left of 160. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ sealed interface AppRoute : NavKey, Parcelable {

/**
* A group's scan card: its invite link as a QR, full screen, with Download. Opened from the
* Show Group Card tile of the group invite sheet, which stays open beneath it. The group's
* Show QR Code tile of the group invite sheet, which stays open beneath it. The group's
* identity rides on the route because the host screen already holds it and the card makes
* no request of its own.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ data class GroupInviteResult(val chatId: ChatId) : Parcelable
@Serializable
enum class InviteLinkAction : Parcelable { Share, Revoke }

/** What the host's Invite sheet (frame 1m) hands back to the screen beneath it to open. */
@Parcelize
enum class InviteHostAction : Parcelable { ShareInvite, ShareJoin, ManageInvites }

/**
* What the host picked on New Invite Link (frame 2d): the preset names, not the presets, because
* this module cannot see `shared:chat`. Presets only; never a key or link.
Expand Down Expand Up @@ -117,6 +121,19 @@ sealed interface ChatStep : FlowStep, Parcelable {
Sheet,
com.getcode.navigation.FullscreenSheet

/**
* Frames 1m to 1m″: a host's link card, Approve new members and Manage Invites, from the
* profile's share circle and Join Requests' empty state. Carries nothing: the card's link is
* the host's own, rebuilt from their invites, and is never on a step.
*/
@Parcelize
@Serializable
data object InviteHost :
ChatStep,
NavigationRetVal<InviteHostAction>,
Sheet,
WrapContentSheet

/**
* Frame 2b: Share Link and Revoke Link for one invite on Manage Invites. Returns the pick and
* leaves the work to Manage Invites, which is still on screen beneath it and owns the revoke
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,17 @@ fun GroupCardScreen(route: AppRoute.Menu.GroupCard) {
val textSize = with(LocalDensity.current) { (width * TextFraction).toSp() }
val lineHeight = textSize * LineHeightRatio

Box(
val inviteLink = viewModel.inviteLink
Column(
modifier = cardModifier
.align(Alignment.Center)
// The avatar's upper half stands outside the card, so the card sits lower by half
// of it and the whole group stays centred.
.offset(y = avatarSize / 2)
.width(width),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
Expand Down Expand Up @@ -94,6 +97,24 @@ fun GroupCardScreen(route: AppRoute.Menu.GroupCard) {
.testTag("group-card-qr"),
)
}
// Frame 1j: an invite-link card says it joins without asking, and until when, so a
// saved or printed card carries its own expiry.
if (inviteLink != null) {
Text(
modifier = Modifier.padding(top = width * QrTopFraction),
text = stringResource(CoreR.string.label_groupCardScanToJoin),
style = CodeTheme.typography.textMedium,
color = CodeTheme.colors.textMain,
)
Text(
modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1),
text = inviteLink.expiresAt
?.let { stringResource(CoreR.string.label_groupCardLinkExpires, cardDate(it)) }
?: stringResource(CoreR.string.label_groupCardLinkNeverExpires),
style = CodeTheme.typography.textSmall,
color = CodeTheme.colors.textSecondary,
)
}
}

// A ring in the screen colour keeps the picture from running into the card.
Expand All @@ -115,9 +136,27 @@ fun GroupCardScreen(route: AppRoute.Menu.GroupCard) {
)
}
}
if (inviteLink != null) {
Text(
modifier = Modifier
.padding(top = CodeTheme.dimens.grid.x4)
.testTag("group-card-invite-footnote"),
text = stringResource(CoreR.string.footnote_groupCardInviteLink),
style = CodeTheme.typography.textSmall,
color = CodeTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
}
}
}
}

/** "Nov 3", as the invite sheet writes a link's dates. */
private fun cardDate(at: kotlin.time.Instant): String =
java.time.format.DateTimeFormatter.ofPattern("MMM d", java.util.Locale.getDefault())
.withZone(java.time.ZoneId.systemDefault())
.format(java.time.Instant.ofEpochMilli(at.toEpochMilliseconds()))

private const val CardWidthFraction = 0.75f

// The profile card's corner and name proportions (TipCard.kt).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.flipcash.app.core.util.Linkify
import com.flipcash.app.shareable.ShareSheetController
import com.flipcash.app.shareable.Shareable
import com.flipcash.features.menu.R
import com.flipcash.shared.chat.GroupCardLinks
import com.getcode.manager.BottomBarManager
import com.getcode.util.resources.ResourceHelper
import dagger.assisted.Assisted
Expand All @@ -23,8 +24,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch

/**
* A group's scan card: the invite link as a QR, exportable as an image. The link is the one
* [Linkify.groupChatInvite] builds, so what the card encodes is what Share and Copy hand out.
* A group's scan card: a link as a QR, exportable as an image. The link is the one the Invite
* People sheet beneath was opened for, handed over in memory through [GroupCardLinks] because the
* route is saved and an invite link is a key. With nothing handed over (a card restored after
* process death) it is the plain link [Linkify.groupChatInvite] builds.
*/
@HiltViewModel(assistedFactory = GroupCardViewModel.Factory::class)
internal class GroupCardViewModel @AssistedInject constructor(
Expand All @@ -33,10 +36,16 @@ internal class GroupCardViewModel @AssistedInject constructor(
private val shareable: ShareSheetController,
private val resources: ResourceHelper,
private val analytics: FlipcashAnalytics,
links: GroupCardLinks,
) : ViewModel() {

/** Null only for an id with no UUID form, which no group has. */
val inviteUrl: String? = Linkify.groupChatInvite(group.chatId)
private val link: GroupCardLinks.Link? = links.cardLink(group.chatId, Linkify.groupChatInvite(group.chatId))

/** The invite link this card encodes, for the lines under the QR; null for the plain link. */
val inviteLink: GroupCardLinks.Link? = link?.takeIf { it.isInvite }

/** Null only for a plain link from an id with no UUID form, which no group has. */
val inviteUrl: String? = link?.url

/** Null when [inviteUrl] is, or if the link cannot be encoded. */
val matrix: QrMatrix? = inviteUrl?.let { runCatching { QrMatrix.encodeGroupCard(it) }.getOrNull() }
Expand Down
Loading
Loading