From b67bfc62ba379b805a6c523f5ba456354f4a736e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:46:56 -0400 Subject: [PATCH 01/23] feat(mnemonic): generate checked-in BIP-39 English wordlist --- .../internal/screens/MessengerScreen.kt | 3 +- .../internal/screens/components/FocusPin.kt | 214 -- .../screens/components/MessageList.kt | 407 +++- .../screens/components/MessageReadReporter.kt | 57 - .../internal/screens/components/MessageRow.kt | 250 -- .../screens/components/ReceiptRules.kt | 93 - .../app/userflags/ResolvedUserFlags.kt | 5 - .../app/userflags/UserFlagsCoordinator.kt | 6 - .../app/userflags/ResolvedUserFlagsTest.kt | 4 - gradle/libs.versions.toml | 2 +- .../Sources/SharedCoreKit/KikCode+Badge.swift | 52 - .../SharedCoreKit/KikCode+Figure.swift | 154 -- .../spm/Sources/SharedCoreKit/SVGPath.swift | 224 -- .../KikCodeFigureTests.swift | 128 - .../com/getcode/crypt/Bip39EnglishWordList.kt | 2057 +++++++++++++++++ .../internal/domain/UserFlagsMapper.kt | 6 - .../com/flipcash/services/models/UserFlags.kt | 8 - .../internal/domain/UserFlagsMapperTest.kt | 23 - 18 files changed, 2397 insertions(+), 1296 deletions(-) delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/FocusPin.kt delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt delete mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Badge.swift delete mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Figure.swift delete mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/SVGPath.swift delete mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/KikCodeFigureTests.swift create mode 100644 libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/Bip39EnglishWordList.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index a7393fd6e0..a0e9dcfa78 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -96,8 +96,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } // Back unwinds the message actions before it leaves the conversation, innermost first: an edit - // in progress, then the selection bar. Registered here rather than around the whole flow so - // they are the innermost handlers and take the gesture before it pops the conversation. + // in progress, then the selection bar. BackHandler(enabled = state.editing != null) { viewModel.dispatchEvent(ChatViewModel.Event.CancelEdit) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/FocusPin.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/FocusPin.kt deleted file mode 100644 index bc5b02dac2..0000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/FocusPin.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.flipcash.app.messenger.internal.screens.components - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.lazy.LazyListItemInfo -import androidx.compose.foundation.lazy.LazyListLayoutInfo -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.layout -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.constrainHeight -import androidx.paging.compose.LazyPagingItems -import com.flipcash.shared.chat.models.ChatListItem - -/** - * Keeps the message a long-press has put in focus where the user left it while the keyboard is up. - * - * The conversation is inset by `Modifier.imePadding()`, so opening the keyboard shortens the - * transcript's viewport from the bottom. The list is `reverseLayout`, i.e. anchored to the bottom, - * so every row rides up by the keyboard height — far enough that a message part-way up the thread - * leaves the top of the screen the moment the keyboard opens over it. - * - * The pin hands the list back exactly the height the keyboard took, letting it overflow behind the - * keyboard: bottom-anchored content then moves down by the same amount it was pushed up and the - * focused row lands back where it started. [slack] caps that at the room the row had above the - * composer, so a message that was already sitting near the bar rides up with the bar rather than - * being buried under the keyboard. - * - * It holds for the whole focus, not just the edit it can lead to. The keyboard comes up on the edit, - * but it also comes up if the composer is tapped while the selection bar is showing, and a selected - * message pushed off the top is the same message lost from under the buttons acting on it. - * - * The compensation is read in the layout pass ([holdFocusedMessageInPlace]), the same phase - * `imePadding` resolves in, so the two move together across the IME animation. A counter-scroll - * driven from an effect would be a frame behind it the whole way down. - */ -@Stable -internal class FocusPin { - var active by mutableStateOf(false) - private set - - /** Bottom inset at the moment the focus began; only growth beyond this has to be cancelled. */ - private var imeAtStart by mutableIntStateOf(0) - - /** The list's own bottom padding at the moment the focus began; see [compensation]. */ - private var bottomPadAtStart by mutableIntStateOf(0) - - /** - * How far the pinned row can travel down before it meets the composer, in px. Measured once, at - * the start: a message arriving later scrolls the row further up and so only ever leaves this - * an underestimate, which caps the compensation low rather than pushing the row back down over - * the scroll that just moved it. - */ - private var slack by mutableIntStateOf(0) - - /** What the pin is still handing back after a focus ends, on its way to nothing. See [release]. */ - private val unwind = Animatable(0f) - - suspend fun arm(imeBottom: Int, bottomPad: Int, slackPx: Int) { - imeAtStart = imeBottom - bottomPadAtStart = bottomPad - slack = slackPx.coerceAtLeast(0) - active = true - // Nothing left of an earlier focus's unwind: while armed the compensation is measured from - // the keyboard, and this is what it falls back to when the arming ends. - unwind.snapTo(0f) - } - - /** - * Ends the pin, giving the height back over [UNWIND_MS] rather than in one frame. - * - * The keyboard outlives the focus — confirming an edit keeps it for the next message, cancelling - * leaves the composer focused — so the transcript has to end up where a keyboard-up transcript - * belongs, which is the pinned row's own height above where the pin was holding it. Dropping the - * compensation is what puts it there; animating the drop is what stops that being a jump. - * - * Handing the amount to the scroll position instead would hold the row still now and cost the - * transcript a keyboard's worth of history later, when the keyboard closes and the height comes - * back with the scroll offset still carrying it. - */ - suspend fun release(imeBottom: Int, bottomPad: Int) { - val carried = compensation(imeBottom, bottomPad).toFloat() - // Ordered so the measured height never changes across the handover: still the same number, - // read from the other branch. - unwind.snapTo(carried) - active = false - if (carried > 0f) unwind.animateTo(0f, tween(UNWIND_MS)) - } - - /** - * Extra height the list needs right now to hold the focused row still, in px. - * - * Two things move the row when the keyboard opens, and both are measured from the start of the - * focus. The keyboard shortens the list, lifting the bottom-anchored content by its height. The - * input bar the list is padded for gets shorter at the same time: it is inside the scaffold's - * `imePadding`, and its own `navigationBarsPadding` subtracts insets already consumed there, so - * the navigation bar it was padding for goes to zero the moment the keyboard covers it. Less - * bottom padding drops the content back down by that much. Handing back the sum of the two - * cancels both; correcting only for the keyboard leaves the row sitting a navigation bar low. - */ - fun compensation(imeBottom: Int, bottomPad: Int): Int = - if (active) { - ((imeBottom - imeAtStart) + (bottomPad - bottomPadAtStart)).coerceIn(0, slack) - } else { - unwind.value.toInt() - } - - private companion object { - const val UNWIND_MS = 200 - } -} - -/** - * The pin for [focusedMessageId] — the selected message, or the one being edited. - * - * Selecting and then editing is one focus, not two: the id doesn't change across that step, so the - * pin isn't re-armed and the measurements it took at the long-press still describe the row. - */ -@Composable -internal fun rememberFocusPin( - listState: LazyListState, - messages: LazyPagingItems, - focusedMessageId: Long?, - imeInsets: WindowInsets, - bottomPadPx: Int, -): FocusPin { - val pin = remember { FocusPin() } - val density = LocalDensity.current - - LaunchedEffect(focusedMessageId) { - if (focusedMessageId != null) { - // The focus starts from a long-press, so the row is on screen and still laid out from - // the frame before. Its offset is the distance from the list's content start, which for - // a reverse list is the bottom edge — and the bottom content padding already covers the - // composer, so that distance is the gap between the row and the bar. - val row = listState.layoutInfo.rowFor(messages, focusedMessageId) - pin.arm( - imeBottom = imeInsets.getBottom(density), - bottomPad = bottomPadPx, - slackPx = row?.offset ?: 0, - ) - } else if (pin.active) { - pin.release(imeInsets.getBottom(density), bottomPadPx) - } - } - - return pin -} - -/** - * Measures the transcript [FocusPin.compensation] px taller than it reports, so its bottom-anchored - * content extends behind the keyboard instead of being pushed up by it. The overflow is only ever - * the strip the keyboard covers, so nothing lands where it can be seen. - */ -internal fun Modifier.holdFocusedMessageInPlace( - pin: FocusPin, - imeInsets: WindowInsets, - bottomPadPx: Int, -): Modifier = - layout { measurable, constraints -> - val extra = pin.compensation(imeInsets.getBottom(this), bottomPadPx) - val placeable = measurable.measure( - constraints.copy( - minHeight = constraints.minHeight + extra, - maxHeight = if (constraints.hasBoundedHeight) { - constraints.maxHeight + extra - } else { - constraints.maxHeight - }, - ) - ) - layout(placeable.width, constraints.constrainHeight(placeable.height - extra)) { - placeable.place(0, 0) - } - } - -/** - * The laid-out row carrying [messageId], if it is on screen. Indices past [messages]'s own count are - * the trailing separator and the contact card, which have no message behind them to peek at. - */ -internal fun LazyListLayoutInfo.rowFor( - messages: LazyPagingItems, - messageId: Long, -): LazyListItemInfo? = visibleItemsInfo.firstOrNull { info -> - info.index < messages.itemCount && - (messages.peek(info.index) as? ChatListItem.ContentBubble)?.messageId == messageId -} - -/** - * Signed gap between the row carrying [messageId] and the lower edge of the top bar, in px: - * positive while the row still has that much room to rise, negative once it has passed under the - * bar by that much. Zero when the row is not laid out. - * - * The list is `reverseLayout`, so an item's offset is measured from the bottom of the content area - * and grows as the item rises. The top of that area is [LazyListLayoutInfo.viewportEndOffset] less - * the padding past it — which is the overlap the top bar reports, so the boundary sits level with - * the bar's lower edge rather than behind it. - */ -internal fun LazyListLayoutInfo.headroomAbove( - messages: LazyPagingItems, - messageId: Long, -): Int { - val row = rowFor(messages, messageId) ?: return 0 - return viewportEndOffset - afterContentPadding - (row.offset + row.size) -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index f4e8aff4eb..24b8d90e24 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -1,43 +1,61 @@ package com.flipcash.app.messenger.internal.screens.components -import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import com.flipcash.app.messenger.internal.screens.ChatAnimations +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer +import androidx.compose.runtime.CompositionLocalProvider import com.flipcash.shared.chat.models.ChatAction import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.ui.ContentBubble import com.flipcash.shared.chat.models.LocalChatActionHandler +import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig +import com.flipcash.shared.chat.ui.bubblePositionOf import com.getcode.theme.CodeTheme +import com.getcode.ui.core.addIf import com.getcode.ui.utils.rememberKeyboardController import com.getcode.util.vibration.LocalVibrator import kotlinx.coroutines.flow.collectLatest @@ -47,6 +65,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun MessageList( modifier: Modifier = Modifier, @@ -102,28 +121,6 @@ internal fun MessageList( // and the contact card at the start of history all stop taking taps together. val selecting = state.selection != null || state.editing != null - // A message can be long-pressed while it is running under the top bar, which the fade - // there makes easy to do — and the bar then swaps to the actions for it, so the row the - // backdrop leaves sharp sits behind the buttons acting on it. Bring it down level with the - // bar's lower edge before that happens. Selecting and then editing is one focus, not two, - // so the key doesn't change across that step and the row isn't scrolled twice. - val focusedMessageId = state.editing?.messageId ?: state.selection?.messageId - LaunchedEffect(focusedMessageId) { - if (focusedMessageId == null) return@LaunchedEffect - val buried = -listState.layoutInfo.headroomAbove(messages, focusedMessageId) - if (buried > 0) listState.animateScrollBy(buried.toFloat()) - } - - // Holds the focused message at the position it was long-pressed at, against the keyboard - // shortening the list from below — whether the keyboard came up for the edit or because the - // composer was tapped with the selection bar still showing. See FocusPin. - val imeInsets = WindowInsets.ime - val listBottomPad = CodeTheme.dimens.grid.x2 + contentPadding.calculateBottomPadding() - val listBottomPadPx = with(LocalDensity.current) { listBottomPad.roundToPx() } - val focusPin = rememberFocusPin(listState, messages, focusedMessageId, imeInsets, listBottomPadPx) - // Read live from the auto-scroll effect below, which outlives the composition it launched in. - val editingMessageId by rememberUpdatedState(state.editing?.messageId) - // Track when the initial Paging refresh has truly completed (Loading → NotLoading). // This avoids showing the ContactInfoContainer before messages arrive, // which would cause the list to start scrolled to the wrong position. @@ -139,12 +136,13 @@ internal fun MessageList( } LazyColumn( - // NB: no sheetResignmentBehavior, unlike every other scrolling list in the app. The - // conversation is a full-screen destination, not a sheet, so there is no dismiss drag - // for the guard to hand the gesture back to — back is the only way out. It would also - // read this list wrong: the guard expects a top-anchored list, where index0/offset0 is - // the edge a downward drag overscrolls past, and this one is reverseLayout, where - // index0/offset0 is the resting position and a downward drag scrolls into history. + // NB: no sheetResignmentBehavior here. That guard is built for top-anchored lists, + // where index0/offset0 is the scroll edge that abuts the sheet's downward dismiss drag. + // This list is reverseLayout=true, so index0/offset0 is the *resting* position (newest), + // and a downward drag there scrolls into history rather than overscrolling. Applying the + // guard would flip the sheet's allowDismiss to false at rest and never cleanly re-enable + // it, so the sheet snaps back instead of dismissing. Dismiss here comes from the + // header/handle drag, scrim tap, and back — all gated by that same allowDismiss flag. modifier = modifier // The backdrop is modal. While a message is selected or being edited the rest of // the transcript sits behind it, so a tap there dismisses what is up rather than @@ -157,18 +155,12 @@ internal fun MessageList( else -> keyboard.hide() } } - } - .holdFocusedMessageInPlace(focusPin, imeInsets, listBottomPadPx), + }, state = listState, reverseLayout = true, - // The transcript can't be dragged for as long as the backdrop is up — selection and - // edit alike. It is behind the same scrim the taps above dismiss, so a drag there has - // no more business reaching it than a tap does. Arriving messages still move it — see - // the auto-scroll effect below, which caps how far. - userScrollEnabled = !selecting, contentPadding = PaddingValues( top = CodeTheme.dimens.inset + contentPadding.calculateTopPadding(), - bottom = listBottomPad, + bottom = CodeTheme.dimens.grid.x2 + contentPadding.calculateBottomPadding(), start = CodeTheme.dimens.inset, end = CodeTheme.dimens.inset, ), @@ -179,13 +171,41 @@ internal fun MessageList( key = messages.itemKey { it.itemKey } ) { index -> val item = messages[index] ?: return@items + val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig) + + val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false + + // Message insertion animation — scale from 0.95 + opacity with edge anchor. + // Only animate genuinely new messages (index 0 after initial load). + val shouldAnimate = index == 0 && hasLoaded && item.itemKey !in animatedKeys + if (shouldAnimate) animatedKeys.add(item.itemKey) + var appeared by remember(item.itemKey) { mutableStateOf(!shouldAnimate) } + LaunchedEffect(Unit) { if (!appeared) appeared = true } + val insertionAlpha by animateFloatAsState( + targetValue = if (appeared) 1f else 0f, + animationSpec = ChatAnimations.insertion, + label = "insertAlpha", + ) + val insertionScale by animateFloatAsState( + targetValue = if (appeared) 1f else 0.95f, + animationSpec = ChatAnimations.insertion, + label = "insertScale", + ) - // Cross-item bookkeeping, so it stays with the list rather than the row: a message - // animates in once, the first time it is laid out after the initial page. - val animateInsertion = index == 0 && hasLoaded && item.itemKey !in animatedKeys - if (animateInsertion) animatedKeys.add(item.itemKey) + val insertionModifier = Modifier.graphicsLayer { + alpha = insertionAlpha + scaleX = insertionScale + scaleY = insertionScale + transformOrigin = if (isOutgoing) { + TransformOrigin(1f, 0.5f) // anchor trailing + } else { + TransformOrigin(0f, 0.5f) // anchor leading + } + } val bubble = item as? ChatListItem.ContentBubble + val interactionSource = remember { MutableInteractionSource() } + // Selecting or editing a message pushes the rest of the transcript behind a blur, // leaving the one message sharp — the same treatment iOS holds from its context // menu through the edit that can follow it. @@ -198,17 +218,125 @@ internal fun MessageList( state.selection != null -> bubble?.itemKey == state.selection.itemKey else -> true } + val dimAlpha by animateFloatAsState( + targetValue = if (focused) 1f else 0.4f, + label = "messageDim", + ) + val dimBlur by animateDpAsState( + targetValue = if (focused) 0.dp else 8.dp, + label = "messageBlur", + ) - MessageRow( - index = index, - item = item, - messages = messages, - separatorConfig = separatorConfig, - otherReadPointer = otherReadPointer, - selecting = selecting, - focused = focused, - animateInsertion = animateInsertion, + // The row answers the finger before the long-press resolves: it dips while held, + // then springs up and stays lifted for as long as it is the selected message. + val pressed by interactionSource.collectIsPressedAsState() + val lift by animateFloatAsState( + targetValue = when { + selecting && focused -> 1.04f + pressed && bubble?.isSelectable == true -> 0.97f + else -> 1f + }, + animationSpec = ChatAnimations.lift, + label = "messageLift", ) + + Box( + modifier = Modifier + .padding(bottom = bottomSpacing) + // Unbounded: the rectangle treatment would clip the blur at the row's own + // edges and leave a hard seam between neighbouring rows. + .blur(dimBlur, BlurredEdgeTreatment.Unbounded) + .graphicsLayer { + alpha = dimAlpha + scaleX = lift + scaleY = lift + // Anchored to the bubble's own edge, as the insertion animation is, so + // the lift grows the bubble in place instead of sliding it inward. + transformOrigin = if (isOutgoing) { + TransformOrigin(1f, 0.5f) + } else { + TransformOrigin(0f, 0.5f) + } + } + // No row gestures while the backdrop is up: the rows are behind it, and a + // press there would move the selection out from under the message the bar — + // or the composer — is already acting on. + .addIf(bubble != null && !selecting) { + // Long-press is the whole row's gesture, not the bubble's: a + // bubble-sized target is harder to hit, and the top bar is what reports + // the selection, so nothing about the row has to change. + Modifier.combinedClickable( + interactionSource = interactionSource, + indication = null, + onLongClick = bubble?.takeIf { it.isSelectable }?.let { target -> + { + vibrator.tick() + onAction(ChatAction.ToggleSelection(target)) + } + }, + // Only reachable with the backdrop down, so the tap has nothing to + // dismiss but the keyboard. + onClick = { keyboard.hide() }, + ) + }, + ) { + when (item) { + is ChatListItem.DateSeparator -> Box(insertionModifier) { + DateSeparatorRow(item.timestamp) + } + + is ChatListItem.ContentBubble -> { + val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) + // Track whether this item was ever seen as SENDING so we + // can animate the receipt label entrance on the + // SENDING→SENT transition. This remember persists across + // recompositions of the same item (keyed by LazyColumn), + // surviving the status change that gates the label. + var wasSending by remember { mutableStateOf(false) } + if (item.receiptStatus == ReceiptStatus.SENDING) { + wasSending = true + } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, + ) { + Box(insertionModifier) { + ContentBubble( + item = item, + // The bubble's own targets go with the row's: a cash + // bubble behind the backdrop would otherwise open token + // info from under the bar. + interactive = !selecting, + position = bubblePositionOf( + index, + item, + messages, + separatorConfig + ), + ) + } + val showReceipt = + shouldShowReceiptLabel(index, item, messages, otherReadPointer) + AnimatedVisibility( + visible = showReceipt && effectiveStatus != null, + enter = EnterTransition.None, + exit = ChatAnimations.receiptExit, + ) { + if (effectiveStatus != null) { + ReceiptLabel( + status = effectiveStatus, + readPointer = otherReadPointer, + animateEntrance = wasSending, + onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) { + { onAction(ChatAction.RetryMessage(item)) } + } else null, + ) + } + } + } + } + } + } } // Show trailing separator and contact info once messages are available @@ -275,20 +403,6 @@ internal fun MessageList( // Always scroll for own messages; only near-bottom for incoming val nearBottom = listState.firstVisibleItemIndex <= 5 val newest = messages.peek(0) as? ChatListItem.ContentBubble - val editing = editingMessageId - if (editing != null) { - // A message arriving mid-edit still moves the transcript, or it would land - // off the bottom edge with nothing to say it had arrived. What it can't do - // is carry the row being edited away: the scroll stops at the point that - // row would pass under the top bar, and the pin holds it there. - if (newest?.isFromSelf == true || nearBottom) { - val room = listState.layoutInfo.headroomAbove(messages, editing) - // Negative is toward index 0. The list settles on the newest message by - // itself if that comes before the cap. - if (room > 0) listState.animateScrollBy(-room.toFloat()) - } - return@collectLatest - } when { // Own message: anchor the new bubble during the next measure pass // rather than animating to it. An animated scroll resolves its target @@ -317,5 +431,160 @@ internal fun MessageList( listState.requestScrollToItem(0, 0) } } + } // CompositionLocalProvider } + +@Composable +private fun bottomSpacingFor( + index: Int, + item: ChatListItem, + messages: LazyPagingItems, + config: SeparatorConfig, +): Dp { + val tight = CodeTheme.dimens.grid.x1 + val normal = CodeTheme.dimens.grid.x2 + val wide = CodeTheme.dimens.grid.x3 + // index-1 is the item below (newer) in reverseLayout + val itemBelow = (if (index > 0) messages.peek(index - 1) else null) ?: return tight + + // Separator adjacent → normal gap + if (item is ChatListItem.DateSeparator || itemBelow is ChatListItem.DateSeparator) { + return normal + } + + val current = item as? ChatListItem.ContentBubble ?: return tight + val below = itemBelow as? ChatListItem.ContentBubble ?: return tight + + return when { + // Different sender → wide + current.isFromSelf != below.isFromSelf -> wide + // Same sender, outside grouping window → normal + !config.isGrouped(current.timestamp, below.timestamp) -> normal + // Same sender, close together → tight + else -> tight + } +} + +@Composable +private fun HandleMessageReads( + listState: LazyListState, + messages: LazyPagingItems, +) { + val actionHandler = LocalChatActionHandler.current + var lastAdvanced by remember { mutableLongStateOf(0L) } + + LaunchedEffect(listState, messages) { + snapshotFlow { + val layout = listState.layoutInfo + val count = messages.itemCount + val visibleRange = layout.visibleItemsInfo + if (visibleRange.isEmpty() || count == 0) return@snapshotFlow null + + var highestId = 0L + for (info in visibleRange) { + if (info.index !in 0 until count) continue + val bubble = messages.peek(info.index) as? ChatListItem.ContentBubble ?: continue + if (!bubble.isFromSelf && bubble.messageId > highestId) { + highestId = bubble.messageId + } + } + if (highestId > 0L) highestId else null + } + .filterNotNull() + .distinctUntilChanged() + .collectLatest { messageId -> + if (messageId > lastAdvanced) { + lastAdvanced = messageId + actionHandler(ChatAction.AdvanceReadPointer(messageId)) + } + } + } +} + +/** + * A tombstone anchors nothing. The receipt describes the delivery of a message that is no longer + * there, so leaving it attached would caption a deleted bubble with "Read". + */ +private val ChatListItem.ContentBubble.carriesReceipt: Boolean + get() = isFromSelf && content !is MessageContent.Deleted + +private fun effectiveReceiptStatus( + bubble: ChatListItem.ContentBubble, + otherReadPointer: MessagePointer?, +): ReceiptStatus? { + if (!bubble.carriesReceipt) return null + val base = bubble.receiptStatus ?: return null + val pointerValue = otherReadPointer?.value ?: 0L + if (base == ReceiptStatus.SENT && bubble.messageId in 1..pointerValue) { + return ReceiptStatus.READ + } + return base +} + +/** + * Show a receipt label below a self-message only within the last 2 contiguous + * self-message groups. In reverseLayout, index 0 is the newest message. + * + * Within a group, labels appear at status boundaries (SENT↔READ). + * At group boundaries, labels are suppressed when the nearest self-group + * below already shows the same status (avoids duplicate "Read" labels). + */ +/** The nearest bubble below [index], stepping over the viewer's own tombstones. */ +private fun receiptNeighbourBelow( + index: Int, + messages: LazyPagingItems, +): ChatListItem.ContentBubble? { + for (i in (index - 1) downTo 0) { + val bubble = messages.peek(i) as? ChatListItem.ContentBubble ?: return null + if (bubble.isFromSelf && bubble.content is MessageContent.Deleted) continue + return bubble + } + return null +} + +private fun shouldShowReceiptLabel( + index: Int, + item: ChatListItem.ContentBubble, + messages: LazyPagingItems, + otherReadPointer: MessagePointer?, +): Boolean { + if (!item.carriesReceipt) return false + val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false + if (status == ReceiptStatus.FAILED) return true + if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false + + // index - 1 is the item below (newer) in reverseLayout. A tombstone still belongs to the self + // group for bubble shaping, so it is stepped over here rather than treated as a group boundary. + val belowBubble = receiptNeighbourBelow(index, messages) + + // Within a self-group: show at intra-group status boundaries only + if (belowBubble != null && belowBubble.isFromSelf) { + return effectiveReceiptStatus(belowBubble, otherReadPointer) != status + } + + // At a group boundary — count which self-group this is. + var selfGroups = 0 + var prevWasSelf = false + for (i in 0 until index) { + val peek = messages.peek(i) ?: break + val bubble = peek as? ChatListItem.ContentBubble + val isSelf = bubble != null && bubble.isFromSelf + if (isSelf && !prevWasSelf) selfGroups++ + prevWasSelf = isSelf + } + if (!prevWasSelf) selfGroups++ // current item starts a new group + if (selfGroups > 2) return false + + // Bottommost self-group always shows its label + if (selfGroups == 1) return true + + // For group 2: show only if status differs from the nearest self-group below + for (i in (index - 1) downTo 0) { + val peek = messages.peek(i) ?: break + val bubble = peek as? ChatListItem.ContentBubble ?: continue + if (bubble.carriesReceipt) return effectiveReceiptStatus(bubble, otherReadPointer) != status + } + return true +} + diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt deleted file mode 100644 index 7d6323cadc..0000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageReadReporter.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.flipcash.app.messenger.internal.screens.components - -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.paging.compose.LazyPagingItems -import com.flipcash.shared.chat.models.ChatAction -import com.flipcash.shared.chat.models.ChatListItem -import com.flipcash.shared.chat.models.LocalChatActionHandler -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull - -/** - * Reports the newest incoming message the viewer has actually had on screen, so the read pointer - * follows the scroll rather than the arrival. - */ -@Composable -internal fun HandleMessageReads( - listState: LazyListState, - messages: LazyPagingItems, -) { - val actionHandler = LocalChatActionHandler.current - var lastAdvanced by remember { mutableLongStateOf(0L) } - - LaunchedEffect(listState, messages) { - snapshotFlow { - val layout = listState.layoutInfo - val count = messages.itemCount - val visibleRange = layout.visibleItemsInfo - if (visibleRange.isEmpty() || count == 0) return@snapshotFlow null - - var highestId = 0L - for (info in visibleRange) { - if (info.index !in 0 until count) continue - val bubble = messages.peek(info.index) as? ChatListItem.ContentBubble ?: continue - if (!bubble.isFromSelf && bubble.messageId > highestId) { - highestId = bubble.messageId - } - } - if (highestId > 0L) highestId else null - } - .filterNotNull() - .distinctUntilChanged() - .collectLatest { messageId -> - if (messageId > lastAdvanced) { - lastAdvanced = messageId - actionHandler(ChatAction.AdvanceReadPointer(messageId)) - } - } - } -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt deleted file mode 100644 index bb1f0d7d46..0000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ /dev/null @@ -1,250 +0,0 @@ -package com.flipcash.app.messenger.internal.screens.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.BlurredEdgeTreatment -import androidx.compose.ui.draw.blur -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.paging.compose.LazyPagingItems -import com.flipcash.app.messenger.internal.screens.ChatAnimations -import com.flipcash.services.models.chat.MessagePointer -import com.flipcash.shared.chat.models.ChatAction -import com.flipcash.shared.chat.models.ChatListItem -import com.flipcash.shared.chat.models.LocalChatActionHandler -import com.flipcash.shared.chat.models.ReceiptStatus -import com.flipcash.shared.chat.models.SeparatorConfig -import com.flipcash.shared.chat.ui.ContentBubble -import com.flipcash.shared.chat.ui.bubblePositionOf -import com.getcode.theme.CodeTheme -import com.getcode.ui.core.addIf -import com.getcode.ui.utils.rememberKeyboardController -import com.getcode.util.vibration.LocalVibrator - -/** - * One row of the transcript: a date separator, or a bubble with the receipt label that can sit under - * it. - * - * The row owns what is a function of itself — its insertion animation, its gestures, its spacing to - * the row below — and takes the rest as flags, because they are decided across the whole list: - * [selecting] is true for every row while the backdrop is up, [focused] for the single row it leaves - * sharp, and [animateInsertion] is granted once per message and never again. - */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -internal fun MessageRow( - index: Int, - item: ChatListItem, - messages: LazyPagingItems, - separatorConfig: SeparatorConfig, - otherReadPointer: MessagePointer?, - selecting: Boolean, - focused: Boolean, - animateInsertion: Boolean, -) { - val onAction = LocalChatActionHandler.current - val vibrator = LocalVibrator.current - val keyboard = rememberKeyboardController() - val bottomSpacing = bottomSpacingFor(index, item, messages, separatorConfig) - - val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false - - // Message insertion animation — scale from 0.95 + opacity with edge anchor. - var appeared by remember(item.itemKey) { mutableStateOf(!animateInsertion) } - LaunchedEffect(Unit) { if (!appeared) appeared = true } - val insertionAlpha by animateFloatAsState( - targetValue = if (appeared) 1f else 0f, - animationSpec = ChatAnimations.insertion, - label = "insertAlpha", - ) - val insertionScale by animateFloatAsState( - targetValue = if (appeared) 1f else 0.95f, - animationSpec = ChatAnimations.insertion, - label = "insertScale", - ) - - val insertionModifier = Modifier.graphicsLayer { - alpha = insertionAlpha - scaleX = insertionScale - scaleY = insertionScale - transformOrigin = if (isOutgoing) { - TransformOrigin(1f, 0.5f) // anchor trailing - } else { - TransformOrigin(0f, 0.5f) // anchor leading - } - } - - val bubble = item as? ChatListItem.ContentBubble - val interactionSource = remember { MutableInteractionSource() } - - val dimAlpha by animateFloatAsState( - targetValue = if (focused) 1f else 0.4f, - label = "messageDim", - ) - val dimBlur by animateDpAsState( - targetValue = if (focused) 0.dp else 8.dp, - label = "messageBlur", - ) - - // The row answers the finger before the long-press resolves: it dips while held, - // then springs up and stays lifted for as long as it is the selected message. - val pressed by interactionSource.collectIsPressedAsState() - val lift by animateFloatAsState( - targetValue = when { - selecting && focused -> 1.04f - pressed && bubble?.isSelectable == true -> 0.97f - else -> 1f - }, - animationSpec = ChatAnimations.lift, - label = "messageLift", - ) - - Box( - modifier = Modifier - .padding(bottom = bottomSpacing) - // Unbounded: the rectangle treatment would clip the blur at the row's own - // edges and leave a hard seam between neighbouring rows. - .blur(dimBlur, BlurredEdgeTreatment.Unbounded) - .graphicsLayer { - alpha = dimAlpha - scaleX = lift - scaleY = lift - // Anchored to the bubble's own edge, as the insertion animation is, so - // the lift grows the bubble in place instead of sliding it inward. - transformOrigin = if (isOutgoing) { - TransformOrigin(1f, 0.5f) - } else { - TransformOrigin(0f, 0.5f) - } - } - // No row gestures while the backdrop is up: the rows are behind it, and a - // press there would move the selection out from under the message the bar — - // or the composer — is already acting on. - .addIf(bubble != null && !selecting) { - // Long-press is the whole row's gesture, not the bubble's: a - // bubble-sized target is harder to hit, and the top bar is what reports - // the selection, so nothing about the row has to change. - Modifier.combinedClickable( - interactionSource = interactionSource, - indication = null, - onLongClick = bubble?.takeIf { it.isSelectable }?.let { target -> - { - vibrator.tick() - onAction(ChatAction.ToggleSelection(target)) - } - }, - // Only reachable with the backdrop down, so the tap has nothing to - // dismiss but the keyboard. - onClick = { keyboard.hide() }, - ) - }, - ) { - when (item) { - is ChatListItem.DateSeparator -> Box(insertionModifier) { - DateSeparatorRow(item.timestamp) - } - - is ChatListItem.ContentBubble -> { - val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) - // Track whether this item was ever seen as SENDING so we - // can animate the receipt label entrance on the - // SENDING→SENT transition. This remember persists across - // recompositions of the same item (keyed by LazyColumn), - // surviving the status change that gates the label. - var wasSending by remember { mutableStateOf(false) } - if (item.receiptStatus == ReceiptStatus.SENDING) { - wasSending = true - } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start, - ) { - Box(insertionModifier) { - ContentBubble( - item = item, - // The bubble's own targets go with the row's: a cash - // bubble behind the backdrop would otherwise open token - // info from under the bar. - interactive = !selecting, - position = bubblePositionOf( - index, - item, - messages, - separatorConfig - ), - ) - } - val showReceipt = - shouldShowReceiptLabel(index, item, messages, otherReadPointer) - AnimatedVisibility( - visible = showReceipt && effectiveStatus != null, - enter = EnterTransition.None, - exit = ChatAnimations.receiptExit, - ) { - if (effectiveStatus != null) { - ReceiptLabel( - status = effectiveStatus, - readPointer = otherReadPointer, - animateEntrance = wasSending, - onRetryFailed = if (effectiveStatus == ReceiptStatus.FAILED) { - { onAction(ChatAction.RetryMessage(item)) } - } else null, - ) - } - } - } - } - } - } -} - -@Composable -private fun bottomSpacingFor( - index: Int, - item: ChatListItem, - messages: LazyPagingItems, - config: SeparatorConfig, -): Dp { - val tight = CodeTheme.dimens.grid.x1 - val normal = CodeTheme.dimens.grid.x2 - val wide = CodeTheme.dimens.grid.x3 - // index-1 is the item below (newer) in reverseLayout - val itemBelow = (if (index > 0) messages.peek(index - 1) else null) ?: return tight - - // Separator adjacent → normal gap - if (item is ChatListItem.DateSeparator || itemBelow is ChatListItem.DateSeparator) { - return normal - } - - val current = item as? ChatListItem.ContentBubble ?: return tight - val below = itemBelow as? ChatListItem.ContentBubble ?: return tight - - return when { - // Different sender → wide - current.isFromSelf != below.isFromSelf -> wide - // Same sender, outside grouping window → normal - !config.isGrouped(current.timestamp, below.timestamp) -> normal - // Same sender, close together → tight - else -> tight - } -} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt deleted file mode 100644 index f003421fb3..0000000000 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptRules.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.flipcash.app.messenger.internal.screens.components - -import androidx.paging.compose.LazyPagingItems -import com.flipcash.services.models.chat.MessageContent -import com.flipcash.services.models.chat.MessagePointer -import com.flipcash.shared.chat.models.ChatListItem -import com.flipcash.shared.chat.models.ReceiptStatus - -/** - * A tombstone anchors nothing. The receipt describes the delivery of a message that is no longer - * there, so leaving it attached would caption a deleted bubble with "Read". - */ -private val ChatListItem.ContentBubble.carriesReceipt: Boolean - get() = isFromSelf && content !is MessageContent.Deleted - -internal fun effectiveReceiptStatus( - bubble: ChatListItem.ContentBubble, - otherReadPointer: MessagePointer?, -): ReceiptStatus? { - if (!bubble.carriesReceipt) return null - val base = bubble.receiptStatus ?: return null - val pointerValue = otherReadPointer?.value ?: 0L - if (base == ReceiptStatus.SENT && bubble.messageId in 1..pointerValue) { - return ReceiptStatus.READ - } - return base -} - -/** - * Show a receipt label below a self-message only within the last 2 contiguous - * self-message groups. In reverseLayout, index 0 is the newest message. - * - * Within a group, labels appear at status boundaries (SENT↔READ). - * At group boundaries, labels are suppressed when the nearest self-group - * below already shows the same status (avoids duplicate "Read" labels). - */ -/** The nearest bubble below [index], stepping over the viewer's own tombstones. */ -private fun receiptNeighbourBelow( - index: Int, - messages: LazyPagingItems, -): ChatListItem.ContentBubble? { - for (i in (index - 1) downTo 0) { - val bubble = messages.peek(i) as? ChatListItem.ContentBubble ?: return null - if (bubble.isFromSelf && bubble.content is MessageContent.Deleted) continue - return bubble - } - return null -} - -internal fun shouldShowReceiptLabel( - index: Int, - item: ChatListItem.ContentBubble, - messages: LazyPagingItems, - otherReadPointer: MessagePointer?, -): Boolean { - if (!item.carriesReceipt) return false - val status = effectiveReceiptStatus(item, otherReadPointer) ?: return false - if (status == ReceiptStatus.FAILED) return true - if (status != ReceiptStatus.SENT && status != ReceiptStatus.READ) return false - - // index - 1 is the item below (newer) in reverseLayout. A tombstone still belongs to the self - // group for bubble shaping, so it is stepped over here rather than treated as a group boundary. - val belowBubble = receiptNeighbourBelow(index, messages) - - // Within a self-group: show at intra-group status boundaries only - if (belowBubble != null && belowBubble.isFromSelf) { - return effectiveReceiptStatus(belowBubble, otherReadPointer) != status - } - - // At a group boundary — count which self-group this is. - var selfGroups = 0 - var prevWasSelf = false - for (i in 0 until index) { - val peek = messages.peek(i) ?: break - val bubble = peek as? ChatListItem.ContentBubble - val isSelf = bubble != null && bubble.isFromSelf - if (isSelf && !prevWasSelf) selfGroups++ - prevWasSelf = isSelf - } - if (!prevWasSelf) selfGroups++ // current item starts a new group - if (selfGroups > 2) return false - - // Bottommost self-group always shows its label - if (selfGroups == 1) return true - - // For group 2: show only if status differs from the nearest self-group below - for (i in (index - 1) downTo 0) { - val peek = messages.peek(i) ?: break - val bubble = peek as? ChatListItem.ContentBubble ?: continue - if (bubble.carriesReceipt) return effectiveReceiptStatus(bubble, otherReadPointer) != status - } - return true -} diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt index a56be7070c..d221f48fb8 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt @@ -37,8 +37,6 @@ data class ResolvedUserFlags( val requireCoinbaseEmailVerification: ResolvedFlag, val tipPresets: ResolvedFlag>, val usernameMinBalance: ResolvedFlag, - val messageEditWindow: ResolvedFlag, - val messageDeleteWindow: ResolvedFlag, ) internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = ResolvedUserFlags( @@ -58,7 +56,4 @@ internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = Resolv requireCoinbaseEmailVerification = ResolvedFlag(requireCoinbaseEmailVerification, overrides.requireCoinbaseEmailVerification), tipPresets = ResolvedFlag(tipPresets, FieldOverride.None), usernameMinBalance = ResolvedFlag(usernameMinBalance, FieldOverride.None), - // Read-only for now — no debug override support until the edit/delete UI lands. - messageEditWindow = ResolvedFlag(messageEditWindow, FieldOverride.None), - messageDeleteWindow = ResolvedFlag(messageDeleteWindow, FieldOverride.None), ) \ No newline at end of file diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt index 395b25986b..805c5f7cd3 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt @@ -182,8 +182,6 @@ private data class CachedFlags( val requireCoinbaseEmailVerification: Boolean, val tipPresets: List = emptyList(), val usernameMinBalance: CachedFiat = CachedFiat(quarks = 0), - val messageEditWindowMillis: Long? = null, - val messageDeleteWindowMillis: Long? = null, ) { fun toDomain(): UserFlags = UserFlags( isStaff = isStaff, @@ -204,8 +202,6 @@ private data class CachedFlags( requireCoinbaseEmailVerification = requireCoinbaseEmailVerification, tipPresets = tipPresets.map { it.toDomain() }, usernameMinBalance = usernameMinBalance.toDomain(), - messageEditWindow = messageEditWindowMillis?.milliseconds, - messageDeleteWindow = messageDeleteWindowMillis?.milliseconds, ) companion object { @@ -230,8 +226,6 @@ private data class CachedFlags( requireCoinbaseEmailVerification = flags.requireCoinbaseEmailVerification, tipPresets = flags.tipPresets.map { CachedTipPresets.fromDomain(it) }, usernameMinBalance = CachedFiat.fromDomain(flags.usernameMinBalance), - messageEditWindowMillis = flags.messageEditWindow?.inWholeMilliseconds, - messageDeleteWindowMillis = flags.messageDeleteWindow?.inWholeMilliseconds, ) } } diff --git a/apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/ResolvedUserFlagsTest.kt b/apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/ResolvedUserFlagsTest.kt index 3c6441187b..ecbb869095 100644 --- a/apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/ResolvedUserFlagsTest.kt +++ b/apps/flipcash/shared/userflags/src/test/kotlin/com/flipcash/app/userflags/ResolvedUserFlagsTest.kt @@ -36,8 +36,6 @@ class ResolvedUserFlagsTest { assertEquals(true, resolved.requireCoinbaseEmailVerification.effectiveValue) assertEquals(ServerFlags.tipPresets, resolved.tipPresets.effectiveValue) assertEquals(Fiat(quarks = 5_000_000L), resolved.usernameMinBalance.effectiveValue) - assertEquals(15.seconds, resolved.messageEditWindow.effectiveValue) - assertEquals(60.seconds, resolved.messageDeleteWindow.effectiveValue) } @Test @@ -144,8 +142,6 @@ private val ServerFlags = UserFlags( TipPresets(region = "US", minimum = 1.0, low = 2.0, medium = 3.0, high = 4.0), ), usernameMinBalance = Fiat(quarks = 5_000_000L), - messageEditWindow = 15.seconds, - messageDeleteWindow = 60.seconds, ) // Every value here differs from the matching server value above, so a flag that reads the diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 035cc07257..c7d8c51558 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.1" # 0.3.0 is the first release of either package to ship R8 keep rules for its generated # messages, which is what lets proguard-rules.pro drop its own. ocp-client-protocol = "0.3.0" -flipcash2-client-protocol = "0.4.0" +flipcash2-client-protocol = "0.3.0" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's # `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata, diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Badge.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Badge.swift deleted file mode 100644 index 58183850c7..0000000000 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Badge.swift +++ /dev/null @@ -1,52 +0,0 @@ -import CoreGraphics -import Foundation -import SharedCore - -public extension KikCode { - - /// The Flipcash badge that sits in a code's middle well. - /// - /// The artwork is shared: Kotlin carries it as one SVG path — the same one [KikCodeSvg] - /// embeds in an export and Android's `ic_logo_round_white` draws — and this hands iOS a - /// `CGPath` of it. Drawing this rather than a bundled image is what keeps the code on - /// screen, the exported PNG, the exported SVG, and Android all showing one figure. - enum Badge { - - /// Side of the square viewport ``path`` is authored in. - public static var viewport: CGFloat { CGFloat(KikCodeBadge.shared.VIEWPORT) } - - /// The shared artwork, as the SVG path data ``path`` is parsed from. - static var artwork: String { KikCodeBadge.shared.PATH_DATA } - - /// The badge in viewport coordinates: a disc with the glyph knocked out of it. - /// - /// - Important: fill this with the even-odd rule. Filled non-zero, the glyph fills in - /// solid and the badge is a plain white disc. - public static let path: CGPath = { - // The artwork is a compile-time constant on the Kotlin side, so a parse failure - // is a change to that constant, not bad input — `badgeArtworkParses` guards it. - (try? SVGPath.parse(artwork)) ?? CGMutablePath() - }() - - /// The badge sized and placed for a code laid out in a `dimension`-sided square, in - /// that code's coordinates. - /// - /// Independent of the payload: every code reserves the same middle well. - public static func path(forCodeOfDimension dimension: CGFloat) -> CGPath { - path(radius: KikCode.badgeRadius(forCodeOfDimension: dimension), center: dimension / 2) - } - - /// The badge drawn to `radius` about `center`, on both axes. - static func path(radius: CGFloat, center: CGFloat) -> CGPath { - let scale = (radius * 2) / viewport - var transform = CGAffineTransform(translationX: center - radius, y: center - radius) - .scaledBy(x: scale, y: scale) - return path.copy(using: &transform) ?? path - } - } - - /// Radius of the middle well in a code laid out in a `dimension`-sided square. - static func badgeRadius(forCodeOfDimension dimension: CGFloat) -> CGFloat { - dimension * CGFloat(KikCodeSpec.shared.OUTER_RATIO) * CGFloat(KikCodeSpec.shared.INNER_RING_RATIO) - } -} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Figure.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Figure.swift deleted file mode 100644 index 52c89b8623..0000000000 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/KikCode+Figure.swift +++ /dev/null @@ -1,154 +0,0 @@ -import CoreGraphics -import Foundation -import SharedCore - -public extension KikCode { - - /// A code resolved into paths ready to fill — the iOS counterpart to Android's - /// `KikCodePainter` and to [KikCodeSvg], all three fed by the same shared geometry. - struct Figure { - - /// Side of the square the paths are laid out in. - public let dimension: CGFloat - - /// Center of the figure, on both axes. - public let center: CGPoint - - /// Radius of the badge well at the middle. - public let badgeRadius: CGFloat - - /// Diameter of a dot, and equivalently the width a run is stroked at. - public let dotDiameter: CGFloat - - /// Every data mark as one path. Fill it; runs arrive already outlined, so there is - /// nothing left to stroke. - public let marks: CGPath - - /// The badge in the middle well, or `nil` if it was left out. - /// - /// - Important: fill this with the even-odd rule; see ``Badge/path``. - public let badge: CGPath? - } - - /// Largest payload a code can carry. - static var maxPayloadBytes: Int { Int(KikCodeSpec.shared.MAX_PAYLOAD_BYTES) } - - /// Lays `payload` out in a `dimension`-sided square and resolves it to drawable paths. - /// - /// - Throws: ``FigureFailure`` if `dimension` isn't positive, or `payload` is empty or - /// longer than ``maxPayloadBytes``. Checked here rather than left to Kotlin, whose own - /// `require` would raise an exception Swift can't catch. - static func figure( - payload: Data, - dimension: CGFloat, - includeBadge: Bool = true - ) throws -> Figure { - guard dimension > 0 else { throw FigureFailure.invalidDimension(dimension) } - guard !payload.isEmpty else { throw FigureFailure.emptyPayload } - guard payload.count <= maxPayloadBytes else { - throw FigureFailure.payloadTooLong(payload.count, maximum: maxPayloadBytes) - } - - let description = KikCodeGeometry.shared.describe( - payload: payload.kotlinByteArray, - dimension: Double(dimension) - ) - - let center = CGFloat(description.center) - let dotDiameter = CGFloat(description.dotDiameter) - let badgeRadius = CGFloat(description.badgeRadius) - - return Figure( - dimension: CGFloat(description.dimension), - center: CGPoint(x: center, y: center), - badgeRadius: badgeRadius, - dotDiameter: dotDiameter, - marks: marksPath(description, center: center, dotDiameter: dotDiameter), - badge: includeBadge ? Badge.path(radius: badgeRadius, center: center) : nil - ) - } - - /// Why a payload couldn't be laid out. - enum FigureFailure: Error { - case invalidDimension(CGFloat) - case emptyPayload - case payloadTooLong(Int, maximum: Int) - } -} - -// MARK: - Marks - - -private extension KikCode { - - /// Collapses the shared marks into a single fillable path. - /// - /// Runs are centerlines widened to `dotDiameter` with round caps, so a run's ends land - /// exactly where its first and last dots would — the same construction [KikCodeSvg] emits - /// as a stroked group. Outlining them here lets the whole figure be one fill. - static func marksPath( - _ description: KikCodeDescription, - center: CGFloat, - dotDiameter: CGFloat - ) -> CGPath { - let dotRadius = dotDiameter / 2 - let middle = CGPoint(x: center, y: center) - - let dots = CGMutablePath() - let centerlines = CGMutablePath() - - for mark in description.marks { - switch mark { - case let dot as KikCodeMarkDot: - dots.addEllipse(in: CGRect( - x: CGFloat(dot.x) - dotRadius, - y: CGFloat(dot.y) - dotRadius, - width: dotDiameter, - height: dotDiameter - )) - - case let ring as KikCodeMarkRing: - centerlines.addEllipse(in: CGRect( - x: center - CGFloat(ring.radius), - y: center - CGFloat(ring.radius), - width: CGFloat(ring.radius) * 2, - height: CGFloat(ring.radius) * 2 - )) - - case let arc as KikCodeMarkArc: - let radius = CGFloat(arc.radius) - let start = CGFloat(arc.startRadians) - let end = start + CGFloat(arc.sweepRadians) - // Move first: `addArc` would otherwise join this arc to the previous one. - centerlines.move(to: CGPoint( - x: center + radius * cos(start), - y: center + radius * sin(start) - )) - // `clockwise: false` sweeps in the direction of increasing angle, which is - // the direction the shared geometry measures its sweeps in. - centerlines.addArc( - center: middle, - radius: radius, - startAngle: start, - endAngle: end, - clockwise: false - ) - - default: - // The mark set is closed on the Kotlin side; a new case is a version skew. - continue - } - } - - let path = CGMutablePath() - path.addPath(dots) - if !centerlines.isEmpty { - path.addPath(centerlines.copy( - strokingWithWidth: dotDiameter, - lineCap: .round, - lineJoin: .round, - miterLimit: 0 - )) - } - return path.copy() ?? path - } -} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SVGPath.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SVGPath.swift deleted file mode 100644 index b4ad9c1215..0000000000 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/SVGPath.swift +++ /dev/null @@ -1,224 +0,0 @@ -import CoreGraphics -import Foundation - -/// Turns the SVG path syntax the shared artwork is authored in into a `CGPath`. -/// -/// Deliberately not a general SVG implementation. It covers the commands an Android vector -/// drawable emits — `M`, `L`, `H`, `V`, `C`, `S`, `Z`, absolute and relative — and reports -/// anything else rather than guessing, so artwork that outgrows this parser fails a test -/// instead of drawing wrong. -enum SVGPath { - - /// Parses `d`, the value of an SVG `path` element's `d` attribute. - static func parse(_ d: String) throws -> CGPath { - let path = CGMutablePath() - var tokens = Tokenizer(d) - - var command: Character? - var point: CGPoint = .zero - var subpathStart: CGPoint = .zero - // Tracked for `S`, whose first control point mirrors the previous curve's second. - var lastControl: CGPoint? - - func coordinate(_ command: Character) throws -> Double { - guard let value = tokens.nextNumber() else { throw Failure.truncatedCommand(command) } - return value - } - - /// Reads a point, folding in the current point when `command` is relative (lowercase). - func nextPoint(_ command: Character) throws -> CGPoint { - let x = try coordinate(command) - let y = try coordinate(command) - guard command.isLowercase else { return CGPoint(x: x, y: y) } - return CGPoint(x: point.x + x, y: point.y + y) - } - - while let token = tokens.next() { - switch token { - case .command(let character): - command = character - - case .number(let value): - // An omitted command repeats the previous one, except that a repeated - // `moveto` draws lines. Push the number back for the handler below. - guard let previous = command else { throw Failure.leadingNumber } - command = (previous == "M") ? "L" : (previous == "m") ? "l" : previous - tokens.pushBack(value) - } - - guard let current = command else { throw Failure.leadingNumber } - - switch current { - case "M", "m": - point = try nextPoint(current) - path.move(to: point) - subpathStart = point - lastControl = nil - - case "L", "l": - point = try nextPoint(current) - path.addLine(to: point) - lastControl = nil - - case "H", "h": - let x = try coordinate(current) - point = CGPoint(x: current.isLowercase ? point.x + x : x, y: point.y) - path.addLine(to: point) - lastControl = nil - - case "V", "v": - let y = try coordinate(current) - point = CGPoint(x: point.x, y: current.isLowercase ? point.y + y : y) - path.addLine(to: point) - lastControl = nil - - case "C", "c": - let control1 = try nextPoint(current) - let control2 = try nextPoint(current) - point = try nextPoint(current) - path.addCurve(to: point, control1: control1, control2: control2) - lastControl = control2 - - case "S", "s": - // With no preceding curve the first control point coincides with the current - // point, which is what the spec asks for. - let control1 = lastControl.map { - CGPoint(x: 2 * point.x - $0.x, y: 2 * point.y - $0.y) - } ?? point - let control2 = try nextPoint(current) - point = try nextPoint(current) - path.addCurve(to: point, control1: control1, control2: control2) - lastControl = control2 - - case "Z", "z": - path.closeSubpath() - point = subpathStart - lastControl = nil - - default: - throw Failure.unsupportedCommand(current) - } - } - - return path.copy() ?? path - } - - enum Failure: Error { - /// A command this parser doesn't implement — most likely `A`, an elliptical arc. - case unsupportedCommand(Character) - /// A command ran out of coordinates before it had all of them. - case truncatedCommand(Character) - /// Coordinates appeared before any command told us what to do with them. - case leadingNumber - } -} - -// MARK: - Tokenizer - - -private extension SVGPath { - - enum Token { - case command(Character) - case number(Double) - } - - /// Splits path data into commands and numbers. - /// - /// SVG lets separators be dropped wherever the split is unambiguous — `20,-0` and - /// `1.5.5` are each two numbers — so numbers end at the first character that can't - /// continue them rather than at whitespace. - struct Tokenizer { - - private let characters: [Character] - private var index: Int = 0 - private var pushedBack: Double? - - init(_ string: String) { - characters = Array(string) - } - - /// Returns a number read ahead of its turn, so a repeated command can re-read it. - mutating func pushBack(_ value: Double) { - pushedBack = value - } - - mutating func next() -> Token? { - if let value = pushedBack { - pushedBack = nil - return .number(value) - } - while true { - skipSeparators() - guard index < characters.count else { return nil } - - let character = characters[index] - if character.isLetter { - index += 1 - return .command(character) - } - // `scanNumber` steps over anything that starts neither a number nor a - // command, so this retries rather than ending the stream early. - if let value = scanNumber() { return .number(value) } - } - } - - /// Reads the next number, refusing to step over a command to find one. - mutating func nextNumber() -> Double? { - if let value = pushedBack { - pushedBack = nil - return value - } - skipSeparators() - guard index < characters.count, !characters[index].isLetter else { return nil } - return scanNumber() - } - - private mutating func skipSeparators() { - while index < characters.count, - characters[index] == "," || characters[index].isWhitespace { - index += 1 - } - } - - private mutating func scanNumber() -> Double? { - let start = index - - if index < characters.count, characters[index] == "-" || characters[index] == "+" { - index += 1 - } - var sawDot = false - while index < characters.count { - let character = characters[index] - if character.isNumber { - index += 1 - } else if character == ".", !sawDot { - sawDot = true - index += 1 - } else { - break - } - } - // An exponent's own sign belongs to the exponent, not to a following number. - if index < characters.count, characters[index] == "e" || characters[index] == "E" { - var lookahead = index + 1 - if lookahead < characters.count, - characters[lookahead] == "-" || characters[lookahead] == "+" { - lookahead += 1 - } - if lookahead < characters.count, characters[lookahead].isNumber { - index = lookahead - while index < characters.count, characters[index].isNumber { - index += 1 - } - } - } - - guard index > start else { - // Not a number and not a letter: skip it so a stray character can't spin. - index += 1 - return nil - } - return Double(String(characters[start..= 0) - #expect(bounds.minY >= 0) - #expect(bounds.maxX <= dimension) - #expect(bounds.maxY <= dimension) - #expect(bounds.width > dimension * 0.9) - } - - @Test func figureLeavesTheWellClearForTheBadge() throws { - let figure = try KikCode.figure(payload: payload, dimension: 512) - - // Nothing is drawn where the badge goes, which is what lets the badge be opaque. - #expect(!figure.marks.contains(figure.center)) - #expect(figure.badge?.contains(figure.center, using: .evenOdd) == false) // the glyph - #expect(figure.badge?.contains( - CGPoint(x: figure.center.x - figure.badgeRadius * 0.9, y: figure.center.y), - using: .evenOdd - ) == true) - } - - @Test func figureCanLeaveTheBadgeOut() throws { - let figure = try KikCode.figure(payload: payload, dimension: 512, includeBadge: false) - - #expect(figure.badge == nil) - } - - /// The figure and the SVG are two renderings of one description; if they disagree about - /// where the badge goes, an exported code and the code on screen show different logos. - @Test func figurePlacesTheBadgeWhereTheSvgDoes() throws { - let dimension: CGFloat = 1024 - let figure = try KikCode.figure(payload: payload, dimension: dimension) - let svg = KikCode.svg(payload: payload, dimension: Double(dimension)) - - let transform = try #require( - svg.split(separator: "\n").first { $0.contains("fill-rule=\"evenodd\"") } - ) - let numbers = transform - .split(whereSeparator: { "() ".contains($0) }) - .compactMap { Double($0) } - let translate = try #require(numbers.first) - let scale = try #require(numbers.last) - - let bounds = figure.badge?.boundingBox - #expect(abs((bounds?.minX ?? 0) - CGFloat(translate)) < 0.01) - #expect(abs((bounds?.width ?? 0) - CGFloat(scale) * 61.665) < 0.02) - } -} diff --git a/libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/Bip39EnglishWordList.kt b/libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/Bip39EnglishWordList.kt new file mode 100644 index 0000000000..de16062b6b --- /dev/null +++ b/libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/Bip39EnglishWordList.kt @@ -0,0 +1,2057 @@ +package com.getcode.crypt + +// Generated once from src/main/res/raw/english.txt (BIP-39 English wordlist, +// 2048 words, alphabetically sorted). Do not hand-edit; regenerate from the raw +// resource file if the wordlist ever needs to change. +internal object Bip39EnglishWordList { + val words: List = listOf( + "abandon", + "ability", + "able", + "about", + "above", + "absent", + "absorb", + "abstract", + "absurd", + "abuse", + "access", + "accident", + "account", + "accuse", + "achieve", + "acid", + "acoustic", + "acquire", + "across", + "act", + "action", + "actor", + "actress", + "actual", + "adapt", + "add", + "addict", + "address", + "adjust", + "admit", + "adult", + "advance", + "advice", + "aerobic", + "affair", + "afford", + "afraid", + "again", + "age", + "agent", + "agree", + "ahead", + "aim", + "air", + "airport", + "aisle", + "alarm", + "album", + "alcohol", + "alert", + "alien", + "all", + "alley", + "allow", + "almost", + "alone", + "alpha", + "already", + "also", + "alter", + "always", + "amateur", + "amazing", + "among", + "amount", + "amused", + "analyst", + "anchor", + "ancient", + "anger", + "angle", + "angry", + "animal", + "ankle", + "announce", + "annual", + "another", + "answer", + "antenna", + "antique", + "anxiety", + "any", + "apart", + "apology", + "appear", + "apple", + "approve", + "april", + "arch", + "arctic", + "area", + "arena", + "argue", + "arm", + "armed", + "armor", + "army", + "around", + "arrange", + "arrest", + "arrive", + "arrow", + "art", + "artefact", + "artist", + "artwork", + "ask", + "aspect", + "assault", + "asset", + "assist", + "assume", + "asthma", + "athlete", + "atom", + "attack", + "attend", + "attitude", + "attract", + "auction", + "audit", + "august", + "aunt", + "author", + "auto", + "autumn", + "average", + "avocado", + "avoid", + "awake", + "aware", + "away", + "awesome", + "awful", + "awkward", + "axis", + "baby", + "bachelor", + "bacon", + "badge", + "bag", + "balance", + "balcony", + "ball", + "bamboo", + "banana", + "banner", + "bar", + "barely", + "bargain", + "barrel", + "base", + "basic", + "basket", + "battle", + "beach", + "bean", + "beauty", + "because", + "become", + "beef", + "before", + "begin", + "behave", + "behind", + "believe", + "below", + "belt", + "bench", + "benefit", + "best", + "betray", + "better", + "between", + "beyond", + "bicycle", + "bid", + "bike", + "bind", + "biology", + "bird", + "birth", + "bitter", + "black", + "blade", + "blame", + "blanket", + "blast", + "bleak", + "bless", + "blind", + "blood", + "blossom", + "blouse", + "blue", + "blur", + "blush", + "board", + "boat", + "body", + "boil", + "bomb", + "bone", + "bonus", + "book", + "boost", + "border", + "boring", + "borrow", + "boss", + "bottom", + "bounce", + "box", + "boy", + "bracket", + "brain", + "brand", + "brass", + "brave", + "bread", + "breeze", + "brick", + "bridge", + "brief", + "bright", + "bring", + "brisk", + "broccoli", + "broken", + "bronze", + "broom", + "brother", + "brown", + "brush", + "bubble", + "buddy", + "budget", + "buffalo", + "build", + "bulb", + "bulk", + "bullet", + "bundle", + "bunker", + "burden", + "burger", + "burst", + "bus", + "business", + "busy", + "butter", + "buyer", + "buzz", + "cabbage", + "cabin", + "cable", + "cactus", + "cage", + "cake", + "call", + "calm", + "camera", + "camp", + "can", + "canal", + "cancel", + "candy", + "cannon", + "canoe", + "canvas", + "canyon", + "capable", + "capital", + "captain", + "car", + "carbon", + "card", + "cargo", + "carpet", + "carry", + "cart", + "case", + "cash", + "casino", + "castle", + "casual", + "cat", + "catalog", + "catch", + "category", + "cattle", + "caught", + "cause", + "caution", + "cave", + "ceiling", + "celery", + "cement", + "census", + "century", + "cereal", + "certain", + "chair", + "chalk", + "champion", + "change", + "chaos", + "chapter", + "charge", + "chase", + "chat", + "cheap", + "check", + "cheese", + "chef", + "cherry", + "chest", + "chicken", + "chief", + "child", + "chimney", + "choice", + "choose", + "chronic", + "chuckle", + "chunk", + "churn", + "cigar", + "cinnamon", + "circle", + "citizen", + "city", + "civil", + "claim", + "clap", + "clarify", + "claw", + "clay", + "clean", + "clerk", + "clever", + "click", + "client", + "cliff", + "climb", + "clinic", + "clip", + "clock", + "clog", + "close", + "cloth", + "cloud", + "clown", + "club", + "clump", + "cluster", + "clutch", + "coach", + "coast", + "coconut", + "code", + "coffee", + "coil", + "coin", + "collect", + "color", + "column", + "combine", + "come", + "comfort", + "comic", + "common", + "company", + "concert", + "conduct", + "confirm", + "congress", + "connect", + "consider", + "control", + "convince", + "cook", + "cool", + "copper", + "copy", + "coral", + "core", + "corn", + "correct", + "cost", + "cotton", + "couch", + "country", + "couple", + "course", + "cousin", + "cover", + "coyote", + "crack", + "cradle", + "craft", + "cram", + "crane", + "crash", + "crater", + "crawl", + "crazy", + "cream", + "credit", + "creek", + "crew", + "cricket", + "crime", + "crisp", + "critic", + "crop", + "cross", + "crouch", + "crowd", + "crucial", + "cruel", + "cruise", + "crumble", + "crunch", + "crush", + "cry", + "crystal", + "cube", + "culture", + "cup", + "cupboard", + "curious", + "current", + "curtain", + "curve", + "cushion", + "custom", + "cute", + "cycle", + "dad", + "damage", + "damp", + "dance", + "danger", + "daring", + "dash", + "daughter", + "dawn", + "day", + "deal", + "debate", + "debris", + "decade", + "december", + "decide", + "decline", + "decorate", + "decrease", + "deer", + "defense", + "define", + "defy", + "degree", + "delay", + "deliver", + "demand", + "demise", + "denial", + "dentist", + "deny", + "depart", + "depend", + "deposit", + "depth", + "deputy", + "derive", + "describe", + "desert", + "design", + "desk", + "despair", + "destroy", + "detail", + "detect", + "develop", + "device", + "devote", + "diagram", + "dial", + "diamond", + "diary", + "dice", + "diesel", + "diet", + "differ", + "digital", + "dignity", + "dilemma", + "dinner", + "dinosaur", + "direct", + "dirt", + "disagree", + "discover", + "disease", + "dish", + "dismiss", + "disorder", + "display", + "distance", + "divert", + "divide", + "divorce", + "dizzy", + "doctor", + "document", + "dog", + "doll", + "dolphin", + "domain", + "donate", + "donkey", + "donor", + "door", + "dose", + "double", + "dove", + "draft", + "dragon", + "drama", + "drastic", + "draw", + "dream", + "dress", + "drift", + "drill", + "drink", + "drip", + "drive", + "drop", + "drum", + "dry", + "duck", + "dumb", + "dune", + "during", + "dust", + "dutch", + "duty", + "dwarf", + "dynamic", + "eager", + "eagle", + "early", + "earn", + "earth", + "easily", + "east", + "easy", + "echo", + "ecology", + "economy", + "edge", + "edit", + "educate", + "effort", + "egg", + "eight", + "either", + "elbow", + "elder", + "electric", + "elegant", + "element", + "elephant", + "elevator", + "elite", + "else", + "embark", + "embody", + "embrace", + "emerge", + "emotion", + "employ", + "empower", + "empty", + "enable", + "enact", + "end", + "endless", + "endorse", + "enemy", + "energy", + "enforce", + "engage", + "engine", + "enhance", + "enjoy", + "enlist", + "enough", + "enrich", + "enroll", + "ensure", + "enter", + "entire", + "entry", + "envelope", + "episode", + "equal", + "equip", + "era", + "erase", + "erode", + "erosion", + "error", + "erupt", + "escape", + "essay", + "essence", + "estate", + "eternal", + "ethics", + "evidence", + "evil", + "evoke", + "evolve", + "exact", + "example", + "excess", + "exchange", + "excite", + "exclude", + "excuse", + "execute", + "exercise", + "exhaust", + "exhibit", + "exile", + "exist", + "exit", + "exotic", + "expand", + "expect", + "expire", + "explain", + "expose", + "express", + "extend", + "extra", + "eye", + "eyebrow", + "fabric", + "face", + "faculty", + "fade", + "faint", + "faith", + "fall", + "false", + "fame", + "family", + "famous", + "fan", + "fancy", + "fantasy", + "farm", + "fashion", + "fat", + "fatal", + "father", + "fatigue", + "fault", + "favorite", + "feature", + "february", + "federal", + "fee", + "feed", + "feel", + "female", + "fence", + "festival", + "fetch", + "fever", + "few", + "fiber", + "fiction", + "field", + "figure", + "file", + "film", + "filter", + "final", + "find", + "fine", + "finger", + "finish", + "fire", + "firm", + "first", + "fiscal", + "fish", + "fit", + "fitness", + "fix", + "flag", + "flame", + "flash", + "flat", + "flavor", + "flee", + "flight", + "flip", + "float", + "flock", + "floor", + "flower", + "fluid", + "flush", + "fly", + "foam", + "focus", + "fog", + "foil", + "fold", + "follow", + "food", + "foot", + "force", + "forest", + "forget", + "fork", + "fortune", + "forum", + "forward", + "fossil", + "foster", + "found", + "fox", + "fragile", + "frame", + "frequent", + "fresh", + "friend", + "fringe", + "frog", + "front", + "frost", + "frown", + "frozen", + "fruit", + "fuel", + "fun", + "funny", + "furnace", + "fury", + "future", + "gadget", + "gain", + "galaxy", + "gallery", + "game", + "gap", + "garage", + "garbage", + "garden", + "garlic", + "garment", + "gas", + "gasp", + "gate", + "gather", + "gauge", + "gaze", + "general", + "genius", + "genre", + "gentle", + "genuine", + "gesture", + "ghost", + "giant", + "gift", + "giggle", + "ginger", + "giraffe", + "girl", + "give", + "glad", + "glance", + "glare", + "glass", + "glide", + "glimpse", + "globe", + "gloom", + "glory", + "glove", + "glow", + "glue", + "goat", + "goddess", + "gold", + "good", + "goose", + "gorilla", + "gospel", + "gossip", + "govern", + "gown", + "grab", + "grace", + "grain", + "grant", + "grape", + "grass", + "gravity", + "great", + "green", + "grid", + "grief", + "grit", + "grocery", + "group", + "grow", + "grunt", + "guard", + "guess", + "guide", + "guilt", + "guitar", + "gun", + "gym", + "habit", + "hair", + "half", + "hammer", + "hamster", + "hand", + "happy", + "harbor", + "hard", + "harsh", + "harvest", + "hat", + "have", + "hawk", + "hazard", + "head", + "health", + "heart", + "heavy", + "hedgehog", + "height", + "hello", + "helmet", + "help", + "hen", + "hero", + "hidden", + "high", + "hill", + "hint", + "hip", + "hire", + "history", + "hobby", + "hockey", + "hold", + "hole", + "holiday", + "hollow", + "home", + "honey", + "hood", + "hope", + "horn", + "horror", + "horse", + "hospital", + "host", + "hotel", + "hour", + "hover", + "hub", + "huge", + "human", + "humble", + "humor", + "hundred", + "hungry", + "hunt", + "hurdle", + "hurry", + "hurt", + "husband", + "hybrid", + "ice", + "icon", + "idea", + "identify", + "idle", + "ignore", + "ill", + "illegal", + "illness", + "image", + "imitate", + "immense", + "immune", + "impact", + "impose", + "improve", + "impulse", + "inch", + "include", + "income", + "increase", + "index", + "indicate", + "indoor", + "industry", + "infant", + "inflict", + "inform", + "inhale", + "inherit", + "initial", + "inject", + "injury", + "inmate", + "inner", + "innocent", + "input", + "inquiry", + "insane", + "insect", + "inside", + "inspire", + "install", + "intact", + "interest", + "into", + "invest", + "invite", + "involve", + "iron", + "island", + "isolate", + "issue", + "item", + "ivory", + "jacket", + "jaguar", + "jar", + "jazz", + "jealous", + "jeans", + "jelly", + "jewel", + "job", + "join", + "joke", + "journey", + "joy", + "judge", + "juice", + "jump", + "jungle", + "junior", + "junk", + "just", + "kangaroo", + "keen", + "keep", + "ketchup", + "key", + "kick", + "kid", + "kidney", + "kind", + "kingdom", + "kiss", + "kit", + "kitchen", + "kite", + "kitten", + "kiwi", + "knee", + "knife", + "knock", + "know", + "lab", + "label", + "labor", + "ladder", + "lady", + "lake", + "lamp", + "language", + "laptop", + "large", + "later", + "latin", + "laugh", + "laundry", + "lava", + "law", + "lawn", + "lawsuit", + "layer", + "lazy", + "leader", + "leaf", + "learn", + "leave", + "lecture", + "left", + "leg", + "legal", + "legend", + "leisure", + "lemon", + "lend", + "length", + "lens", + "leopard", + "lesson", + "letter", + "level", + "liar", + "liberty", + "library", + "license", + "life", + "lift", + "light", + "like", + "limb", + "limit", + "link", + "lion", + "liquid", + "list", + "little", + "live", + "lizard", + "load", + "loan", + "lobster", + "local", + "lock", + "logic", + "lonely", + "long", + "loop", + "lottery", + "loud", + "lounge", + "love", + "loyal", + "lucky", + "luggage", + "lumber", + "lunar", + "lunch", + "luxury", + "lyrics", + "machine", + "mad", + "magic", + "magnet", + "maid", + "mail", + "main", + "major", + "make", + "mammal", + "man", + "manage", + "mandate", + "mango", + "mansion", + "manual", + "maple", + "marble", + "march", + "margin", + "marine", + "market", + "marriage", + "mask", + "mass", + "master", + "match", + "material", + "math", + "matrix", + "matter", + "maximum", + "maze", + "meadow", + "mean", + "measure", + "meat", + "mechanic", + "medal", + "media", + "melody", + "melt", + "member", + "memory", + "mention", + "menu", + "mercy", + "merge", + "merit", + "merry", + "mesh", + "message", + "metal", + "method", + "middle", + "midnight", + "milk", + "million", + "mimic", + "mind", + "minimum", + "minor", + "minute", + "miracle", + "mirror", + "misery", + "miss", + "mistake", + "mix", + "mixed", + "mixture", + "mobile", + "model", + "modify", + "mom", + "moment", + "monitor", + "monkey", + "monster", + "month", + "moon", + "moral", + "more", + "morning", + "mosquito", + "mother", + "motion", + "motor", + "mountain", + "mouse", + "move", + "movie", + "much", + "muffin", + "mule", + "multiply", + "muscle", + "museum", + "mushroom", + "music", + "must", + "mutual", + "myself", + "mystery", + "myth", + "naive", + "name", + "napkin", + "narrow", + "nasty", + "nation", + "nature", + "near", + "neck", + "need", + "negative", + "neglect", + "neither", + "nephew", + "nerve", + "nest", + "net", + "network", + "neutral", + "never", + "news", + "next", + "nice", + "night", + "noble", + "noise", + "nominee", + "noodle", + "normal", + "north", + "nose", + "notable", + "note", + "nothing", + "notice", + "novel", + "now", + "nuclear", + "number", + "nurse", + "nut", + "oak", + "obey", + "object", + "oblige", + "obscure", + "observe", + "obtain", + "obvious", + "occur", + "ocean", + "october", + "odor", + "off", + "offer", + "office", + "often", + "oil", + "okay", + "old", + "olive", + "olympic", + "omit", + "once", + "one", + "onion", + "online", + "only", + "open", + "opera", + "opinion", + "oppose", + "option", + "orange", + "orbit", + "orchard", + "order", + "ordinary", + "organ", + "orient", + "original", + "orphan", + "ostrich", + "other", + "outdoor", + "outer", + "output", + "outside", + "oval", + "oven", + "over", + "own", + "owner", + "oxygen", + "oyster", + "ozone", + "pact", + "paddle", + "page", + "pair", + "palace", + "palm", + "panda", + "panel", + "panic", + "panther", + "paper", + "parade", + "parent", + "park", + "parrot", + "party", + "pass", + "patch", + "path", + "patient", + "patrol", + "pattern", + "pause", + "pave", + "payment", + "peace", + "peanut", + "pear", + "peasant", + "pelican", + "pen", + "penalty", + "pencil", + "people", + "pepper", + "perfect", + "permit", + "person", + "pet", + "phone", + "photo", + "phrase", + "physical", + "piano", + "picnic", + "picture", + "piece", + "pig", + "pigeon", + "pill", + "pilot", + "pink", + "pioneer", + "pipe", + "pistol", + "pitch", + "pizza", + "place", + "planet", + "plastic", + "plate", + "play", + "please", + "pledge", + "pluck", + "plug", + "plunge", + "poem", + "poet", + "point", + "polar", + "pole", + "police", + "pond", + "pony", + "pool", + "popular", + "portion", + "position", + "possible", + "post", + "potato", + "pottery", + "poverty", + "powder", + "power", + "practice", + "praise", + "predict", + "prefer", + "prepare", + "present", + "pretty", + "prevent", + "price", + "pride", + "primary", + "print", + "priority", + "prison", + "private", + "prize", + "problem", + "process", + "produce", + "profit", + "program", + "project", + "promote", + "proof", + "property", + "prosper", + "protect", + "proud", + "provide", + "public", + "pudding", + "pull", + "pulp", + "pulse", + "pumpkin", + "punch", + "pupil", + "puppy", + "purchase", + "purity", + "purpose", + "purse", + "push", + "put", + "puzzle", + "pyramid", + "quality", + "quantum", + "quarter", + "question", + "quick", + "quit", + "quiz", + "quote", + "rabbit", + "raccoon", + "race", + "rack", + "radar", + "radio", + "rail", + "rain", + "raise", + "rally", + "ramp", + "ranch", + "random", + "range", + "rapid", + "rare", + "rate", + "rather", + "raven", + "raw", + "razor", + "ready", + "real", + "reason", + "rebel", + "rebuild", + "recall", + "receive", + "recipe", + "record", + "recycle", + "reduce", + "reflect", + "reform", + "refuse", + "region", + "regret", + "regular", + "reject", + "relax", + "release", + "relief", + "rely", + "remain", + "remember", + "remind", + "remove", + "render", + "renew", + "rent", + "reopen", + "repair", + "repeat", + "replace", + "report", + "require", + "rescue", + "resemble", + "resist", + "resource", + "response", + "result", + "retire", + "retreat", + "return", + "reunion", + "reveal", + "review", + "reward", + "rhythm", + "rib", + "ribbon", + "rice", + "rich", + "ride", + "ridge", + "rifle", + "right", + "rigid", + "ring", + "riot", + "ripple", + "risk", + "ritual", + "rival", + "river", + "road", + "roast", + "robot", + "robust", + "rocket", + "romance", + "roof", + "rookie", + "room", + "rose", + "rotate", + "rough", + "round", + "route", + "royal", + "rubber", + "rude", + "rug", + "rule", + "run", + "runway", + "rural", + "sad", + "saddle", + "sadness", + "safe", + "sail", + "salad", + "salmon", + "salon", + "salt", + "salute", + "same", + "sample", + "sand", + "satisfy", + "satoshi", + "sauce", + "sausage", + "save", + "say", + "scale", + "scan", + "scare", + "scatter", + "scene", + "scheme", + "school", + "science", + "scissors", + "scorpion", + "scout", + "scrap", + "screen", + "script", + "scrub", + "sea", + "search", + "season", + "seat", + "second", + "secret", + "section", + "security", + "seed", + "seek", + "segment", + "select", + "sell", + "seminar", + "senior", + "sense", + "sentence", + "series", + "service", + "session", + "settle", + "setup", + "seven", + "shadow", + "shaft", + "shallow", + "share", + "shed", + "shell", + "sheriff", + "shield", + "shift", + "shine", + "ship", + "shiver", + "shock", + "shoe", + "shoot", + "shop", + "short", + "shoulder", + "shove", + "shrimp", + "shrug", + "shuffle", + "shy", + "sibling", + "sick", + "side", + "siege", + "sight", + "sign", + "silent", + "silk", + "silly", + "silver", + "similar", + "simple", + "since", + "sing", + "siren", + "sister", + "situate", + "six", + "size", + "skate", + "sketch", + "ski", + "skill", + "skin", + "skirt", + "skull", + "slab", + "slam", + "sleep", + "slender", + "slice", + "slide", + "slight", + "slim", + "slogan", + "slot", + "slow", + "slush", + "small", + "smart", + "smile", + "smoke", + "smooth", + "snack", + "snake", + "snap", + "sniff", + "snow", + "soap", + "soccer", + "social", + "sock", + "soda", + "soft", + "solar", + "soldier", + "solid", + "solution", + "solve", + "someone", + "song", + "soon", + "sorry", + "sort", + "soul", + "sound", + "soup", + "source", + "south", + "space", + "spare", + "spatial", + "spawn", + "speak", + "special", + "speed", + "spell", + "spend", + "sphere", + "spice", + "spider", + "spike", + "spin", + "spirit", + "split", + "spoil", + "sponsor", + "spoon", + "sport", + "spot", + "spray", + "spread", + "spring", + "spy", + "square", + "squeeze", + "squirrel", + "stable", + "stadium", + "staff", + "stage", + "stairs", + "stamp", + "stand", + "start", + "state", + "stay", + "steak", + "steel", + "stem", + "step", + "stereo", + "stick", + "still", + "sting", + "stock", + "stomach", + "stone", + "stool", + "story", + "stove", + "strategy", + "street", + "strike", + "strong", + "struggle", + "student", + "stuff", + "stumble", + "style", + "subject", + "submit", + "subway", + "success", + "such", + "sudden", + "suffer", + "sugar", + "suggest", + "suit", + "summer", + "sun", + "sunny", + "sunset", + "super", + "supply", + "supreme", + "sure", + "surface", + "surge", + "surprise", + "surround", + "survey", + "suspect", + "sustain", + "swallow", + "swamp", + "swap", + "swarm", + "swear", + "sweet", + "swift", + "swim", + "swing", + "switch", + "sword", + "symbol", + "symptom", + "syrup", + "system", + "table", + "tackle", + "tag", + "tail", + "talent", + "talk", + "tank", + "tape", + "target", + "task", + "taste", + "tattoo", + "taxi", + "teach", + "team", + "tell", + "ten", + "tenant", + "tennis", + "tent", + "term", + "test", + "text", + "thank", + "that", + "theme", + "then", + "theory", + "there", + "they", + "thing", + "this", + "thought", + "three", + "thrive", + "throw", + "thumb", + "thunder", + "ticket", + "tide", + "tiger", + "tilt", + "timber", + "time", + "tiny", + "tip", + "tired", + "tissue", + "title", + "toast", + "tobacco", + "today", + "toddler", + "toe", + "together", + "toilet", + "token", + "tomato", + "tomorrow", + "tone", + "tongue", + "tonight", + "tool", + "tooth", + "top", + "topic", + "topple", + "torch", + "tornado", + "tortoise", + "toss", + "total", + "tourist", + "toward", + "tower", + "town", + "toy", + "track", + "trade", + "traffic", + "tragic", + "train", + "transfer", + "trap", + "trash", + "travel", + "tray", + "treat", + "tree", + "trend", + "trial", + "tribe", + "trick", + "trigger", + "trim", + "trip", + "trophy", + "trouble", + "truck", + "true", + "truly", + "trumpet", + "trust", + "truth", + "try", + "tube", + "tuition", + "tumble", + "tuna", + "tunnel", + "turkey", + "turn", + "turtle", + "twelve", + "twenty", + "twice", + "twin", + "twist", + "two", + "type", + "typical", + "ugly", + "umbrella", + "unable", + "unaware", + "uncle", + "uncover", + "under", + "undo", + "unfair", + "unfold", + "unhappy", + "uniform", + "unique", + "unit", + "universe", + "unknown", + "unlock", + "until", + "unusual", + "unveil", + "update", + "upgrade", + "uphold", + "upon", + "upper", + "upset", + "urban", + "urge", + "usage", + "use", + "used", + "useful", + "useless", + "usual", + "utility", + "vacant", + "vacuum", + "vague", + "valid", + "valley", + "valve", + "van", + "vanish", + "vapor", + "various", + "vast", + "vault", + "vehicle", + "velvet", + "vendor", + "venture", + "venue", + "verb", + "verify", + "version", + "very", + "vessel", + "veteran", + "viable", + "vibrant", + "vicious", + "victory", + "video", + "view", + "village", + "vintage", + "violin", + "virtual", + "virus", + "visa", + "visit", + "visual", + "vital", + "vivid", + "vocal", + "voice", + "void", + "volcano", + "volume", + "vote", + "voyage", + "wage", + "wagon", + "wait", + "walk", + "wall", + "walnut", + "want", + "warfare", + "warm", + "warrior", + "wash", + "wasp", + "waste", + "water", + "wave", + "way", + "wealth", + "weapon", + "wear", + "weasel", + "weather", + "web", + "wedding", + "weekend", + "weird", + "welcome", + "west", + "wet", + "whale", + "what", + "wheat", + "wheel", + "when", + "where", + "whip", + "whisper", + "wide", + "width", + "wife", + "wild", + "will", + "win", + "window", + "wine", + "wing", + "wink", + "winner", + "winter", + "wire", + "wisdom", + "wise", + "wish", + "witness", + "wolf", + "woman", + "wonder", + "wood", + "wool", + "word", + "work", + "world", + "worry", + "worth", + "wrap", + "wreck", + "wrestle", + "wrist", + "write", + "wrong", + "yard", + "year", + "yellow", + "you", + "young", + "youth", + "zebra", + "zero", + "zone", + "zoo", + ) +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserFlagsMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserFlagsMapper.kt index 415dcdad99..c55b96d1d4 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserFlagsMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserFlagsMapper.kt @@ -34,12 +34,6 @@ internal class UserFlagsMapper @Inject constructor(): requireCoinbaseEmailVerification = from.requireCoinbaseEmailVerification, tipPresets = from.tipPresetsList.map { it.toDomain() }, usernameMinBalance = Fiat(quarks = from.usernameMinBalance), - messageEditWindow = if (from.hasMessageEditWindow()) { - from.messageEditWindow.seconds.toDuration(DurationUnit.SECONDS) - } else null, - messageDeleteWindow = if (from.hasMessageDeleteWindow()) { - from.messageDeleteWindow.seconds.toDuration(DurationUnit.SECONDS) - } else null, ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt index c73e00f017..fcdac22492 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt @@ -26,12 +26,6 @@ data class UserFlags( val tipPresets: List, // USDF that must be held across all currencies before a username can be set. val usernameMinBalance: Fiat, - // Duration after message creation when a message can be edited. Absent when - // the server hasn't set a window (not the same as a zero-length window). - val messageEditWindow: Duration?, - // Duration after message creation when a message can be deleted. Absent when - // the server hasn't set a window (not the same as a zero-length window). - val messageDeleteWindow: Duration?, ) { companion object { val Default = UserFlags( @@ -51,8 +45,6 @@ data class UserFlags( requireCoinbaseEmailVerification = false, tipPresets = emptyList(), usernameMinBalance = Fiat.Zero, - messageEditWindow = null, - messageDeleteWindow = null, ) } } \ No newline at end of file diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserFlagsMapperTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserFlagsMapperTest.kt index 76babb3270..b48e78de53 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserFlagsMapperTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/domain/UserFlagsMapperTest.kt @@ -189,27 +189,4 @@ class UserFlagsMapperTest { assertTrue(result.tipPresets.isEmpty()) } - - @Test - fun `maps message edit and delete windows when set`() { - val proto = FlipcashAccountService.UserFlags.newBuilder() - .setMessageEditWindow(com.google.protobuf.Duration.newBuilder().setSeconds(15).build()) - .setMessageDeleteWindow(com.google.protobuf.Duration.newBuilder().setSeconds(60).build()) - .build() - - val result = mapper.map(proto) - - assertEquals(15.seconds, result.messageEditWindow) - assertEquals(60.seconds, result.messageDeleteWindow) - } - - @Test - fun `message edit and delete windows are null when unset, not zero`() { - val proto = userFlags { } - - val result = mapper.map(proto) - - assertNull(result.messageEditWindow) - assertNull(result.messageDeleteWindow) - } } From 0552497bc7bec7352cafe7cf30f9a4e8c4a68985 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:50:54 -0400 Subject: [PATCH 02/23] chore(mnemonic): convert build.gradle.kts to the KMP module shape androidDeviceTest needs getByName("androidDeviceTest") rather than a direct block accessor, matching the existing androidHostTest pattern in :libs:encryption:utils. --- libs/encryption/mnemonic/build.gradle.kts | 68 +++++++++++++++++------ 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/libs/encryption/mnemonic/build.gradle.kts b/libs/encryption/mnemonic/build.gradle.kts index 57db319e15..b92b9b932c 100644 --- a/libs/encryption/mnemonic/build.gradle.kts +++ b/libs/encryption/mnemonic/build.gradle.kts @@ -1,26 +1,58 @@ plugins { - alias(libs.plugins.flipcash.android.library) - alias(libs.plugins.flipcash.android.ed25519.shadow) + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.test.fixtures) } -android { - namespace = "${Gradle.codeNamespace}.encryption.mnemonic" +// Compiles `src/commonTest/resources` into a generated `TestFixtures.kt` on `commonTest` -- +// see the `flipcash.kmp.test.fixtures` convention plugin. +testFixtures { + packageName = "com.getcode.crypt" } -dependencies { - implementation(project(":libs:encryption:base58")) - implementation(project(":libs:encryption:ed25519")) - implementation(project(":libs:encryption:hmac")) - implementation(project(":libs:encryption:sha256")) - implementation(project(":libs:encryption:sha512")) - implementation(project(":libs:encryption:utils")) - implementation(libs.grpc.okhttp) - implementation(libs.grpc.kotlin) - implementation(libs.androidx.core) +kotlin { + android { + namespace = "com.getcode.encryption.mnemonic" + compileSdk = 37 + minSdk = 29 + withHostTest {} + withDeviceTest {} + } - testImplementation(kotlin("test")) + iosArm64() + iosSimulatorArm64() + iosX64() - // Cross-platform derivation test-vector gate (instrumented: wordlist res/raw + JNI ed25519). - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.test.runner) + sourceSets { + commonMain { + dependencies { + implementation(project(":libs:encryption:ed25519")) + implementation(project(":libs:encryption:hmac")) + implementation(project(":libs:encryption:sha256")) + implementation(project(":libs:encryption:sha512")) + implementation(project(":libs:encryption:utils")) + } + } + commonTest { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.serialization.json) + } + } + androidMain { + dependencies { + // Legacy vendored Base58 codec (fromEntropyB58/getBase58EncodedEntropy). + implementation(project(":libs:encryption:base58")) + // Domain.kt uses android.net.Uri. + implementation(libs.androidx.core) + } + } + getByName("androidDeviceTest") { + dependencies { + // Cross-platform derivation test-vector gate (instrumented: JNI ed25519 pipeline). + implementation(libs.androidx.junit) + implementation(libs.androidx.test.runner) + } + } + } } From 8282961966ae14908a7e1e93f4dfe0f08145b038 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:51:06 -0400 Subject: [PATCH 03/23] refactor(mnemonic): move MnemonicException to commonMain --- .../kotlin/com/getcode/crypt/MnemonicException.kt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename libs/encryption/mnemonic/src/{main => commonMain}/kotlin/com/getcode/crypt/MnemonicException.kt (100%) diff --git a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicException.kt b/libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/MnemonicException.kt similarity index 100% rename from libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicException.kt rename to libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/MnemonicException.kt From 53c2e953dbb26e383923421c1fa4773b4d393cab Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:51:21 -0400 Subject: [PATCH 04/23] refactor(mnemonic): move DerivePath to commonMain --- .../kotlin/com/getcode/crypt/DerivePath.kt | 29 ++----------------- 1 file changed, 2 insertions(+), 27 deletions(-) rename libs/encryption/mnemonic/src/{main => commonMain}/kotlin/com/getcode/crypt/DerivePath.kt (74%) diff --git a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivePath.kt b/libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/DerivePath.kt similarity index 74% rename from libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivePath.kt rename to libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/DerivePath.kt index 9e876ef1df..16fe8756c8 100644 --- a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivePath.kt +++ b/libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/DerivePath.kt @@ -1,7 +1,5 @@ package com.getcode.crypt -import com.getcode.model.Domain - class DerivePath(val indexes: List, val password: String? = null) { fun stringRepresentation(): String { val components = indexes.joinToString(separator) { it.stringRepresentation() } @@ -10,9 +8,7 @@ class DerivePath(val indexes: List, val password: String? = null) { override fun equals(other: Any?): Boolean { if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as DerivePath + if (other !is DerivePath) return false if (indexes != other.indexes) return false @@ -45,23 +41,6 @@ class DerivePath(val indexes: List, val password: String? = null) { return DerivePath(indexes, password) } - // Primary - m/44'/501'/0/0 - // - // Incoming - m/44'/501'/0/0/i/2 - // Outgoing - m/44'/501'/0/0/i/3 - // - // Relationship - m/44'/501'/0/0/0/0 - // Swap - m/44'/501'/0/0/1/0 * - // etc. - m/44'/501'/0/0/2/0 * - // - // Bucket1 - m/44'/501'/0/0/0/1 - // Bucket10 - m/44'/501'/0/0/0/10 - // Bucket100 - m/44'/501'/0/0/0/100 - // Bucket1k - m/44'/501'/0/0/0/1000 - // Bucket10k - m/44'/501'/0/0/0/10000 - // Bucket100k - m/44'/501'/0/0/0/100000 - // Bucket1m - m/44'/501'/0/0/0/1000000 - val bucket1 = newInstance("m/44'/501'/0'/0'/0'/1")!! val bucket10 = newInstance("m/44'/501'/0'/0'/0'/10")!! val bucket100 = newInstance("m/44'/501'/0'/0'/0'/100")!! @@ -88,12 +67,8 @@ class DerivePath(val indexes: List, val password: String? = null) { return newInstance("m/44'/501'/0'/0'/2335'/$index'")!! } - fun relationship(domain: Domain): DerivePath { - return newInstance("m/44'/501'/0'/0'/0'/0", password = domain.relationshipHost)!! - } - private const val identifier = "m" private const val separator = "/" private const val hardener = "'" } -} \ No newline at end of file +} From 8ad02ea2884c1d5ba965e99fe3abd6e09b490de9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:51:38 -0400 Subject: [PATCH 05/23] refactor(mnemonic): relocate DerivePath.relationship(domain:) to androidMain --- .../kotlin/com/getcode/crypt/DerivePath.android.kt | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/DerivePath.android.kt diff --git a/libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/DerivePath.android.kt b/libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/DerivePath.android.kt new file mode 100644 index 0000000000..102f3202c5 --- /dev/null +++ b/libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/DerivePath.android.kt @@ -0,0 +1,7 @@ +package com.getcode.crypt + +import com.getcode.model.Domain + +fun DerivePath.Companion.relationship(domain: Domain): DerivePath { + return DerivePath.newInstance("m/44'/501'/0'/0'/0'/0", password = domain.relationshipHost)!! +} From e2a49c9fce2de626490fb01da3da7f66f65b55f7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:51:43 -0400 Subject: [PATCH 06/23] refactor(mnemonic): move Domain.kt to androidMain --- .../src/{main => androidMain}/kotlin/com/getcode/model/Domain.kt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename libs/encryption/mnemonic/src/{main => androidMain}/kotlin/com/getcode/model/Domain.kt (100%) diff --git a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/model/Domain.kt b/libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/model/Domain.kt similarity index 100% rename from libs/encryption/mnemonic/src/main/kotlin/com/getcode/model/Domain.kt rename to libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/model/Domain.kt From 839231dc0543cf5491bc10a3013a9dd9381335fd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:52:10 -0400 Subject: [PATCH 07/23] refactor(mnemonic): port MnemonicCode to commonMain as an object --- .../kotlin/com/getcode/crypt/MnemonicCode.kt | 138 ++++++++++ .../kotlin/com/getcode/crypt/MnemonicCode.kt | 237 ------------------ 2 files changed, 138 insertions(+), 237 deletions(-) create mode 100644 libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/MnemonicCode.kt delete mode 100644 libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCode.kt diff --git a/libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/MnemonicCode.kt b/libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/MnemonicCode.kt new file mode 100644 index 0000000000..6c193b31fe --- /dev/null +++ b/libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/MnemonicCode.kt @@ -0,0 +1,138 @@ +package com.getcode.crypt + +/* + * Copyright 2013 Ken Sedgwick + * Copyright 2014 Andreas Schildbach + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Converts between binary seed values and lists of words per the + * [BIP 39 specification](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki). + */ +object MnemonicCode { + + private val wordList: List = Bip39EnglishWordList.words + + /** UNIX time for when the BIP39 standard was finalised. Use as a default seed birthday. */ + var BIP39_STANDARDISATION_TIME_SECS: Long = 1381276800L + + private const val PBKDF2_ROUNDS = 2048 + + /** Returns the word list this instance uses. */ + fun getWordList(): List = wordList + + /** + * Converts mnemonic word list to original entropy bytes. + * + * @throws MnemonicException.MnemonicLengthException if the word count is not a multiple of 3 or the list is empty + * @throws MnemonicException.MnemonicWordException if a word is not in the word list + * @throws MnemonicException.MnemonicChecksumException if the checksum does not match + */ + @Throws(MnemonicException::class) + fun toEntropy(words: List): ByteArray { + if (words.size % 3 != 0) + throw MnemonicException.MnemonicLengthException("Word list size must be multiple of three words.") + if (words.isEmpty()) + throw MnemonicException.MnemonicLengthException("Word list is empty.") + + val concatLenBits = words.size * 11 + val concatBits = BooleanArray(concatLenBits) + var wordIndex = 0 + for (word in words) { + val ndx = wordList.binarySearch(word) + if (ndx < 0) throw MnemonicException.MnemonicWordException(word) + for (ii in 0 until 11) + concatBits[wordIndex * 11 + ii] = (ndx and (1 shl (10 - ii))) != 0 + wordIndex++ + } + + val checksumLengthBits = concatLenBits / 33 + val entropyLengthBits = concatLenBits - checksumLengthBits + + val entropy = ByteArray(entropyLengthBits / 8) + for (ii in entropy.indices) + for (jj in 0 until 8) + if (concatBits[ii * 8 + jj]) + entropy[ii] = (entropy[ii].toInt() or (1 shl (7 - jj))).toByte() + + val hash = Sha256Hash.hash(entropy) + val hashBits = bytesToBits(hash) + + for (i in 0 until checksumLengthBits) + if (concatBits[entropyLengthBits + i] != hashBits[i]) + throw MnemonicException.MnemonicChecksumException() + + return entropy + } + + /** + * Converts entropy bytes to a mnemonic word list. + * + * @throws MnemonicException.MnemonicLengthException if the entropy length is not a multiple of 4 or is empty + */ + @Throws(MnemonicException.MnemonicLengthException::class) + fun toMnemonic(entropy: ByteArray): List { + if (entropy.size % 4 != 0) + throw MnemonicException.MnemonicLengthException("Entropy length not multiple of 32 bits.") + if (entropy.isEmpty()) + throw MnemonicException.MnemonicLengthException("Entropy is empty.") + + val hash = Sha256Hash.hash(entropy) + val hashBits = bytesToBits(hash) + val entropyBits = bytesToBits(entropy) + val checksumLengthBits = entropyBits.size / 32 + + val concatBits = BooleanArray(entropyBits.size + checksumLengthBits) + entropyBits.copyInto(concatBits, destinationOffset = 0) + hashBits.copyInto(concatBits, destinationOffset = entropyBits.size, endIndex = checksumLengthBits) + + val words = ArrayList() + val nwords = concatBits.size / 11 + for (i in 0 until nwords) { + var index = 0 + for (j in 0 until 11) { + index = index shl 1 + if (concatBits[i * 11 + j]) index = index or 0x1 + } + words.add(wordList[index]) + } + return words + } + + /** + * Checks whether a mnemonic word list is valid. + * + * @throws MnemonicException if validation fails + */ + @Throws(MnemonicException::class) + fun check(words: List) { + toEntropy(words) + } + + /** Converts a mnemonic word list to a 64-byte PBKDF2-SHA512 seed. */ + fun toSeed(words: List, passphrase: String): ByteArray { + val pass = words.joinToString(" ") + val salt = "mnemonic$passphrase" + return PBKDF2SHA512.derive(pass, salt, PBKDF2_ROUNDS, 64) + } + + private fun bytesToBits(data: ByteArray): BooleanArray { + val bits = BooleanArray(data.size * 8) + for (i in data.indices) + for (j in 0 until 8) + bits[i * 8 + j] = (data[i].toInt() and (1 shl (7 - j))) != 0 + return bits + } +} diff --git a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCode.kt b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCode.kt deleted file mode 100644 index 3a305e20da..0000000000 --- a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCode.kt +++ /dev/null @@ -1,237 +0,0 @@ -package com.getcode.crypt - -/* - * Copyright 2013 Ken Sedgwick - * Copyright 2014 Andreas Schildbach - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import android.content.res.Resources -import com.getcode.encryption.mnemonic.R -import com.getcode.utils.Utils -import com.google.common.base.Stopwatch -import timber.log.Timber -import java.io.BufferedReader -import java.io.FileNotFoundException -import java.io.IOException -import java.io.InputStream -import java.io.InputStreamReader -import java.security.MessageDigest - -/** - * Converts between binary seed values and lists of words per the - * [BIP 39 specification](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki). - */ -class MnemonicCode { - - var wordList: ArrayList = ArrayList() - private set - - /** - * Initialises from the included word list (requires an [android.content.res.Resources] - * instance to open the raw resource; not usable on bare JVM). - */ - @Throws(IOException::class) - constructor(resources: Resources?) { - val stream = openDefaultWords(resources) ?: return - init(stream, BIP39_ENGLISH_SHA256) - } - - /** - * Initialises with words read from [wordstream]. If [wordListDigest] is non-null the - * SHA-256 hex digest of the word list is verified against it. - */ - @Throws(IOException::class, IllegalArgumentException::class) - constructor(wordstream: InputStream?, wordListDigest: String?) { - wordstream ?: return - init(wordstream, wordListDigest) - } - - @Throws(IOException::class) - private fun init(wordstream: InputStream, wordListDigest: String?) { - val br = BufferedReader(InputStreamReader(wordstream, Charsets.UTF_8)) - val list = ArrayList(2048) - val md: MessageDigest = MessageDigest.getInstance("SHA-256") - br.forEachLine { word -> - md.update(word.toByteArray()) - list.add(word) - } - br.close() - - if (list.size != 2048) throw IllegalArgumentException("input stream did not contain 2048 words") - - if (wordListDigest != null) { - val hexDigest = md.digest().toHexString() - if (hexDigest != wordListDigest) throw IllegalArgumentException("wordlist digest mismatch") - } - - wordList = list - } - - /** Returns the word list this instance uses. */ - fun getWordList(): List = wordList - - /** - * Converts mnemonic word list to original entropy bytes. - * - * @throws MnemonicException.MnemonicLengthException if the word count is not a multiple of 3 or the list is empty - * @throws MnemonicException.MnemonicWordException if a word is not in the word list - * @throws MnemonicException.MnemonicChecksumException if the checksum does not match - */ - @Throws(MnemonicException::class) - fun toEntropy(words: List): ByteArray { - if (words.size % 3 != 0) - throw MnemonicException.MnemonicLengthException("Word list size must be multiple of three words.") - if (words.isEmpty()) - throw MnemonicException.MnemonicLengthException("Word list is empty.") - - val concatLenBits = words.size * 11 - val concatBits = BooleanArray(concatLenBits) - var wordIndex = 0 - for (word in words) { - val ndx = wordList.binarySearch(word) - if (ndx < 0) throw MnemonicException.MnemonicWordException(word) - for (ii in 0 until 11) - concatBits[wordIndex * 11 + ii] = (ndx and (1 shl (10 - ii))) != 0 - wordIndex++ - } - - val checksumLengthBits = concatLenBits / 33 - val entropyLengthBits = concatLenBits - checksumLengthBits - - val entropy = ByteArray(entropyLengthBits / 8) - for (ii in entropy.indices) - for (jj in 0 until 8) - if (concatBits[ii * 8 + jj]) - entropy[ii] = (entropy[ii].toInt() or (1 shl (7 - jj))).toByte() - - val hash = Sha256Hash.hash(entropy) - val hashBits = bytesToBits(hash) - - for (i in 0 until checksumLengthBits) - if (concatBits[entropyLengthBits + i] != hashBits[i]) - throw MnemonicException.MnemonicChecksumException() - - return entropy - } - - /** - * Converts entropy bytes to a mnemonic word list. - * - * @throws MnemonicException.MnemonicLengthException if the entropy length is not a multiple of 4 or is empty - */ - @Throws(MnemonicException.MnemonicLengthException::class) - fun toMnemonic(entropy: ByteArray): List { - if (entropy.size % 4 != 0) - throw MnemonicException.MnemonicLengthException("Entropy length not multiple of 32 bits.") - if (entropy.isEmpty()) - throw MnemonicException.MnemonicLengthException("Entropy is empty.") - - val hash = Sha256Hash.hash(entropy) - val hashBits = bytesToBits(hash) - val entropyBits = bytesToBits(entropy) - val checksumLengthBits = entropyBits.size / 32 - - val concatBits = BooleanArray(entropyBits.size + checksumLengthBits) - entropyBits.copyInto(concatBits, destinationOffset = 0) - hashBits.copyInto(concatBits, destinationOffset = entropyBits.size, endIndex = checksumLengthBits) - - val words = ArrayList() - val nwords = concatBits.size / 11 - for (i in 0 until nwords) { - var index = 0 - for (j in 0 until 11) { - index = index shl 1 - if (concatBits[i * 11 + j]) index = index or 0x1 - } - words.add(wordList[index]) - } - return words - } - - /** - * Checks whether a mnemonic word list is valid. - * - * @throws MnemonicException if validation fails - */ - @Throws(MnemonicException::class) - fun check(words: List) { - toEntropy(words) - } - - companion object { - const val TAG: String = "MnemonicCode" - - private const val BIP39_ENGLISH_RESOURCE_NAME = "english.txt" - private const val BIP39_ENGLISH_SHA256 = "ad90bf3beb7b0eb7e5acd74727dc0da96e0a280a258354e7293fb7e211ac03db" - - /** UNIX time for when the BIP39 standard was finalised. Use as a default seed birthday. */ - @JvmField - var BIP39_STANDARDISATION_TIME_SECS: Long = 1381276800L - - private const val PBKDF2_ROUNDS = 2048 - - /** Shared instance (null until set, e.g. via [MnemonicCache.init]). */ - @JvmField - var INSTANCE: MnemonicCode? = null - - init { - try { - INSTANCE = MnemonicCode(null as Resources?) - } catch (e: FileNotFoundException) { - if (!Utils.isAndroidRuntime()) Timber.e("Could not find word list") - } catch (e: IOException) { - Timber.e("Failed to load word list") - } - } - - /** Converts a mnemonic word list to a 64-byte PBKDF2-SHA512 seed. */ - @JvmStatic - fun toSeed(words: List, passphrase: String): ByteArray { - requireNotNull(passphrase) { "A null passphrase is not allowed." } - val pass = words.joinToString(" ") - val salt = "mnemonic$passphrase" - val watch = Stopwatch.createStarted() - val seed = PBKDF2SHA512.derive(pass, salt, PBKDF2_ROUNDS, 64) - watch.stop() - Timber.i("PBKDF2 took {} %s", watch) - return seed - } - - private fun openDefaultWords(resources: Resources?): InputStream? { - resources ?: return null - val stream = resources.openRawResource(R.raw.english) - ?: throw FileNotFoundException(BIP39_ENGLISH_RESOURCE_NAME) - return stream - } - - private fun bytesToBits(data: ByteArray): BooleanArray { - val bits = BooleanArray(data.size * 8) - for (i in data.indices) - for (j in 0 until 8) - bits[i * 8 + j] = (data[i].toInt() and (1 shl (7 - j))) != 0 - return bits - } - - private fun ByteArray.toHexString(): String { - val sb = StringBuilder(size * 2) - for (b in this) { - val v = b.toInt() and 0xFF - sb.append("0123456789abcdef"[v ushr 4]) - sb.append("0123456789abcdef"[v and 0x0F]) - } - return sb.toString() - } - } -} From 7897f0920f42c92703c83ba08ef7efdb87bcfa35 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:52:32 -0400 Subject: [PATCH 08/23] refactor(mnemonic): port Derive to commonMain, return raw key bytes --- .../kotlin/com/getcode/crypt/Derive.kt | 45 +++++++++---------- 1 file changed, 22 insertions(+), 23 deletions(-) rename libs/encryption/mnemonic/src/{main => commonMain}/kotlin/com/getcode/crypt/Derive.kt (55%) diff --git a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/Derive.kt b/libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/Derive.kt similarity index 55% rename from libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/Derive.kt rename to libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/Derive.kt index f96269a72c..7debb46780 100644 --- a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/Derive.kt +++ b/libs/encryption/mnemonic/src/commonMain/kotlin/com/getcode/crypt/Derive.kt @@ -1,37 +1,38 @@ package com.getcode.crypt -import com.getcode.ed25519.Ed25519 -import com.getcode.utils.encodeBase64 import com.getcode.utils.subByteArray -import java.nio.ByteBuffer - +/// Deterministic wallet generation for ED25519 curve using SLIP-0010 spec +/// Reference: https://github.com/satoshilabs/slips/blob/master/slip-0010.md object Derive { private const val curve = "ed25519 seed" private const val algorithm = "HmacSHA512" - private const val hardenedOffset = 0x80000000 + private const val hardenedOffset = 0x80000000L + + /** Derives the raw 32-byte private key for [path] (defaults to [DerivePath.primary]) from [seed]. */ + fun derivedKey(seed: ByteArray, path: DerivePath? = null): ByteArray { + val indexes = (path?.indexes ?: DerivePath.primary.indexes).map { hardenedOffset + it.value } + return derivedKey(seed, indexes.toLongArray()) + } - fun path(seed: ByteArray, path: DerivePath? = null): Ed25519.KeyPair { + /** Derives the raw 32-byte private key by walking [hardenedIndexes] (each already offset by 0x80000000) from [seed]. */ + fun derivedKey(seed: ByteArray, hardenedIndexes: LongArray): ByteArray { var descriptor = masterKey(seed) - (path?.indexes?.map { it } ?: DerivePath.primary.indexes).forEach { index -> - descriptor = CKDPriv( - keyDescriptor = descriptor, - index = hardenedOffset + index.value - ) + hardenedIndexes.forEach { index -> + descriptor = CKDPriv(keyDescriptor = descriptor, index = index) } - - return Ed25519.createKeyPair(descriptor.key.encodeBase64()) + return descriptor.key } private fun CKDPriv(keyDescriptor: KeyDescriptor, index: Long): KeyDescriptor { + val i = index.toInt() val entropy = mutableListOf() entropy.add(0) entropy.addAll(keyDescriptor.key.toList()) - - ByteBuffer.allocate(Int.SIZE_BYTES).apply { - putInt(index.toInt()) - entropy.addAll(array().toList()) - } + entropy.add((i ushr 24).toByte()) + entropy.add((i ushr 16).toByte()) + entropy.add((i ushr 8).toByte()) + entropy.add(i.toByte()) return split32( hmac(key = keyDescriptor.chain, message = entropy.toByteArray()) @@ -39,7 +40,7 @@ object Derive { } private fun masterKey(seed: ByteArray): KeyDescriptor { - val descriptor = hmac(curve.toByteArray(), seed) + val descriptor = hmac(curve.encodeToByteArray(), seed) return split32(descriptor) } @@ -57,9 +58,7 @@ object Derive { data class KeyDescriptor(val key: ByteArray, val chain: ByteArray) { override fun equals(other: Any?): Boolean { if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as KeyDescriptor + if (other !is KeyDescriptor) return false if (!key.contentEquals(other.key)) return false if (!chain.contentEquals(other.chain)) return false @@ -73,4 +72,4 @@ object Derive { return result } } -} \ No newline at end of file +} From d878dff340a550d7e324e2bc0a41b7ae6459329f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:52:46 -0400 Subject: [PATCH 09/23] refactor(mnemonic): move MnemonicPhrase to androidMain, adapt to Derive.derivedKey --- .../kotlin/com/getcode/crypt/MnemonicPhrase.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename libs/encryption/mnemonic/src/{main => androidMain}/kotlin/com/getcode/crypt/MnemonicPhrase.kt (95%) diff --git a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicPhrase.kt b/libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/MnemonicPhrase.kt similarity index 95% rename from libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicPhrase.kt rename to libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/MnemonicPhrase.kt index a0f7533898..c8cd5bd23d 100644 --- a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicPhrase.kt +++ b/libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/MnemonicPhrase.kt @@ -25,7 +25,7 @@ class MnemonicPhrase(val kind: Kind, val words: List) { mnemonicCode.check(words) - return Derive.path(mnemonicSeed, path) + return Ed25519.createKeyPair(Derive.derivedKey(mnemonicSeed, path).encodeBase64()) } val wordString: String @@ -74,4 +74,4 @@ class MnemonicPhrase(val kind: Kind, val words: List) { return newInstance(words)!! } } -} \ No newline at end of file +} From 422a99258900d9ae2b571e11917ae7d82ec58d4b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:53:00 -0400 Subject: [PATCH 10/23] refactor(mnemonic): simplify MnemonicCache.init() to a no-op --- .../kotlin/com/getcode/crypt/MnemonicCache.kt | 15 +++++++++++++++ .../kotlin/com/getcode/crypt/MnemonicCache.kt | 14 -------------- 2 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/MnemonicCache.kt delete mode 100644 libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCache.kt diff --git a/libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/MnemonicCache.kt b/libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/MnemonicCache.kt new file mode 100644 index 0000000000..44b068ffd9 --- /dev/null +++ b/libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/MnemonicCache.kt @@ -0,0 +1,15 @@ +package com.getcode.crypt + +import android.content.Context + +object MnemonicCache { + val cachedCode: MnemonicCode = MnemonicCode + + fun init(context: Context) { + // MnemonicCode no longer needs Android resources -- the wordlist is a compile-time + // constant (see Bip39EnglishWordList). Kept so MnemonicCacheInitializer's startup + // call still compiles. + } + + val cache = mutableMapOf, String>, ByteArray>() +} diff --git a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCache.kt b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCache.kt deleted file mode 100644 index 949740b19f..0000000000 --- a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCache.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.getcode.crypt - -import android.content.Context - -object MnemonicCache { - lateinit var cachedCode: MnemonicCode - private set - - fun init(context: Context) { - cachedCode = MnemonicCode(context.resources) - } - - val cache = mutableMapOf, String>, ByteArray>() -} \ No newline at end of file From 789d917f8914d1d393346d9e2834e07be46f5592 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:53:11 -0400 Subject: [PATCH 11/23] refactor(mnemonic): move DerivedKey to androidMain --- .../kotlin/com/getcode/crypt/DerivedKey.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename libs/encryption/mnemonic/src/{main => androidMain}/kotlin/com/getcode/crypt/DerivedKey.kt (99%) diff --git a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivedKey.kt b/libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/DerivedKey.kt similarity index 99% rename from libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivedKey.kt rename to libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/DerivedKey.kt index 412d3b34e2..a4536b5429 100644 --- a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/DerivedKey.kt +++ b/libs/encryption/mnemonic/src/androidMain/kotlin/com/getcode/crypt/DerivedKey.kt @@ -31,4 +31,4 @@ data class DerivedKey(val path: DerivePath, val keyPair: Ed25519.KeyPair) { return result } -} \ No newline at end of file +} From dd546069c74292fa6348452b52eca129c5fec073 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:53:17 -0400 Subject: [PATCH 12/23] refactor(mnemonic): move DerivePathTest to commonTest --- .../kotlin/com/getcode/crypt/DerivePathTest.kt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename libs/encryption/mnemonic/src/{test => commonTest}/kotlin/com/getcode/crypt/DerivePathTest.kt (100%) diff --git a/libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/DerivePathTest.kt b/libs/encryption/mnemonic/src/commonTest/kotlin/com/getcode/crypt/DerivePathTest.kt similarity index 100% rename from libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/DerivePathTest.kt rename to libs/encryption/mnemonic/src/commonTest/kotlin/com/getcode/crypt/DerivePathTest.kt From 369470af76c150d3c01c7d33c5cec2026a645e3e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:53:23 -0400 Subject: [PATCH 13/23] refactor(mnemonic): move MnemonicPhraseTest to androidHostTest --- .../kotlin/com/getcode/crypt/MnemonicPhraseTest.kt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename libs/encryption/mnemonic/src/{test => androidHostTest}/kotlin/com/getcode/crypt/MnemonicPhraseTest.kt (100%) diff --git a/libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/MnemonicPhraseTest.kt b/libs/encryption/mnemonic/src/androidHostTest/kotlin/com/getcode/crypt/MnemonicPhraseTest.kt similarity index 100% rename from libs/encryption/mnemonic/src/test/kotlin/com/getcode/crypt/MnemonicPhraseTest.kt rename to libs/encryption/mnemonic/src/androidHostTest/kotlin/com/getcode/crypt/MnemonicPhraseTest.kt From c7510fd777f9ca1706454d20bd88437fb99faa28 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:57:29 -0400 Subject: [PATCH 14/23] test(mnemonic): add SLIP-10 canonical vector gate test Verifies BIP39 seed -> SLIP-0010 ed25519 derivation against slip10.json on the JVM host, mirroring the ed25519/base58 commonTest gates from #718. The androidHostTest JVM run needs the same JNI-backed Ed25519Kmp Android actual as :libs:encryption:ed25519's own host test, which loads libnative-lib.dylib/libed25519.dylib from a src/androidHostTest/jniLibs directory. mnemonic didn't have one, so createKeyPair() failed with UnsatisfiedLinkError; copying the same two prebuilt libraries in fixes it. --- .../androidHostTest/jniLibs/libed25519.dylib | Bin 0 -> 85632 bytes .../jniLibs/libnative-lib.dylib | Bin 0 -> 147760 bytes .../crypt/Slip10DerivationVectorTest.kt | 62 ++++++++++++++++ .../src/commonTest/resources/slip10.json | 66 ++++++++++++++++++ 4 files changed, 128 insertions(+) create mode 100755 libs/encryption/mnemonic/src/androidHostTest/jniLibs/libed25519.dylib create mode 100755 libs/encryption/mnemonic/src/androidHostTest/jniLibs/libnative-lib.dylib create mode 100644 libs/encryption/mnemonic/src/commonTest/kotlin/com/getcode/crypt/Slip10DerivationVectorTest.kt create mode 100644 libs/encryption/mnemonic/src/commonTest/resources/slip10.json diff --git a/libs/encryption/mnemonic/src/androidHostTest/jniLibs/libed25519.dylib b/libs/encryption/mnemonic/src/androidHostTest/jniLibs/libed25519.dylib new file mode 100755 index 0000000000000000000000000000000000000000..09cc85ffa92b7bedba22072ada69fee01a1b094f GIT binary patch literal 85632 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 DzSwzD literal 0 HcmV?d00001 diff --git a/libs/encryption/mnemonic/src/androidHostTest/jniLibs/libnative-lib.dylib b/libs/encryption/mnemonic/src/androidHostTest/jniLibs/libnative-lib.dylib new file mode 100755 index 0000000000000000000000000000000000000000..b12ffa424b5a7914c998eb995a6821d147a31b60 GIT binary patch literal 147760 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 literal 0 HcmV?d00001 diff --git a/libs/encryption/mnemonic/src/commonTest/kotlin/com/getcode/crypt/Slip10DerivationVectorTest.kt b/libs/encryption/mnemonic/src/commonTest/kotlin/com/getcode/crypt/Slip10DerivationVectorTest.kt new file mode 100644 index 0000000000..50b456534c --- /dev/null +++ b/libs/encryption/mnemonic/src/commonTest/kotlin/com/getcode/crypt/Slip10DerivationVectorTest.kt @@ -0,0 +1,62 @@ +package com.getcode.crypt + +import com.getcode.ed25519kmp.Ed25519Kmp +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * GATE: BIP39 seed -> SLIP-0010 ed25519 derivation must reproduce canonical vectors on BOTH + * Android (JVM host) and iOS (native). + * Fixture: src/commonTest/resources/slip10.json (canonical source: test-vectors/slip10.json). + */ +class Slip10DerivationVectorTest { + + @Test + fun derivation_matches_canonical_vectors() { + val vectors = loadVectors() + assertTrue(vectors.isNotEmpty(), "no vectors loaded from slip10.json") + + for (v in vectors) { + val seed = MnemonicCode.toSeed(v.mnemonic.split(" "), v.passphrase) + val path = DerivePath.newInstance(v.path)!! + val derivedKey = Derive.derivedKey(seed, path) + assertEquals(v.derivedKey, derivedKey.toHex(), "derivedKey mismatch for '${v.name}'") + + val keyPair = Ed25519Kmp.createKeyPair(derivedKey) + assertEquals(v.publicKey, keyPair.publicKey.toHex(), "publicKey mismatch for '${v.name}'") + } + } + + private data class Vector( + val name: String, + val mnemonic: String, + val passphrase: String, + val path: String, + val derivedKey: String, + val publicKey: String, + ) + + private fun loadVectors(): List { + val text = readTestResource("slip10.json") + val root = Json.parseToJsonElement(text).jsonObject + return root["vectors"]!!.jsonArray.map { el -> + val o = el.jsonObject + Vector( + name = o["name"]!!.jsonPrimitive.content, + mnemonic = o["mnemonic"]!!.jsonPrimitive.content, + passphrase = o["passphrase"]!!.jsonPrimitive.content, + path = o["path"]!!.jsonPrimitive.content, + derivedKey = o["derivedKey"]!!.jsonPrimitive.content, + publicKey = o["publicKey"]!!.jsonPrimitive.content, + ) + } + } + + private fun ByteArray.toHex(): String = + joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } +} diff --git a/libs/encryption/mnemonic/src/commonTest/resources/slip10.json b/libs/encryption/mnemonic/src/commonTest/resources/slip10.json new file mode 100644 index 0000000000..fbb0e3331e --- /dev/null +++ b/libs/encryption/mnemonic/src/commonTest/resources/slip10.json @@ -0,0 +1,66 @@ +{ + "algorithm": "bip39+slip10-ed25519", + "note": "BIP39 seed -> SLIP-0010 ed25519 (all indices force-hardened) -> ed25519 keypair. Apps must reproduce publicKey/address for each mnemonic+path.", + "vectors": [ + { + "name": "abandon-x11-about m/44'/501'/0'/0'", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "passphrase": "", + "path": "m/44'/501'/0'/0'", + "seedBip39": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4", + "derivedKey": "37df573b3ac4ad5b522e064e25b63ea16bcbe79d449e81a0268d1047948bb445", + "publicKey": "f036276246a75b9de3349ed42b15e232f6518fc20f5fcd4f1d64e81f9bd258f7", + "address": "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + }, + { + "name": "abandon-x11-about m/44'/501'/0'/0'/7665'/0", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "passphrase": "", + "path": "m/44'/501'/0'/0'/7665'/0", + "seedBip39": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4", + "derivedKey": "1f317a98c39b1459a28fbc5d7e4ed9c9799dc69d194157ec7b9d645c4ed3a474", + "publicKey": "051d88bbb9c70cc1045090c3c01675620b060123f2d1f1700d8ef1f5dadcc5d8", + "address": "LyACZbC6QzPP3ixxyBjuCC1sKWuAi1bZU1xgTuFVpRD" + }, + { + "name": "abandon-x11-about m/44'/501'/0'/0'/2335'/5", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "passphrase": "", + "path": "m/44'/501'/0'/0'/2335'/5", + "seedBip39": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4", + "derivedKey": "4b83957e1b5ca6b16e9f39fdd38c88a6a96c38e61f9b4cecb7fda43e5e249764", + "publicKey": "7bc1de0ce3459814c74dae0529df060261d896dea23264e298eb90d1d236c123", + "address": "9L6c3GSoNLLXbXAoxCgHR1fkhbX96jNaLqmQnsRYUCo4" + }, + { + "name": "legal-winner m/44'/501'/0'/0'", + "mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow", + "passphrase": "", + "path": "m/44'/501'/0'/0'", + "seedBip39": "878386efb78845b3355bd15ea4d39ef97d179cb712b77d5c12b6be415fffeffe5f377ba02bf3f8544ab800b955e51fbff09828f682052a20faa6addbbddfb096", + "derivedKey": "6987bdb06aa8a243a3019f41489ffa8e609c953a885a748d1849a8df760aa479", + "publicKey": "999d46fb3d1256f7049c8ed09314d7268612e8a91b800e91934463848305c98c", + "address": "BLeUXTx9thHGT7VJUtF9vHEmfMDgW1nnKZ9UVer2CoLX" + }, + { + "name": "legal-winner m/44'/501'/0'/0'/7665'/0", + "mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow", + "passphrase": "", + "path": "m/44'/501'/0'/0'/7665'/0", + "seedBip39": "878386efb78845b3355bd15ea4d39ef97d179cb712b77d5c12b6be415fffeffe5f377ba02bf3f8544ab800b955e51fbff09828f682052a20faa6addbbddfb096", + "derivedKey": "82c2531aae3c058eb076618c696c3d496c3249b066dd528cb83adc4f4bbe8294", + "publicKey": "9ed602ebfe2399e961e3a08535a50fd4391fdefc0341b1c790e0c4947ccd16a0", + "address": "Bh2gxt6UiDWjWkmsfK9qp46kRrjTPgvXn7DhvaPTxUr3" + }, + { + "name": "legal-winner m/44'/501'/0'/0'/2335'/5", + "mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow", + "passphrase": "", + "path": "m/44'/501'/0'/0'/2335'/5", + "seedBip39": "878386efb78845b3355bd15ea4d39ef97d179cb712b77d5c12b6be415fffeffe5f377ba02bf3f8544ab800b955e51fbff09828f682052a20faa6addbbddfb096", + "derivedKey": "4eb3bb8632aa83691f51a4cff44021ff785df069432007bb023e858b6416f6ec", + "publicKey": "b5310f3190a4a39d5146f17072a767fb5c566593e4f2417d293d11fcd3b246f7", + "address": "DCJBY1iQroEzsNi2BMwu3zNmnB27rCV6iJHHJDAqTTmg" + } + ] +} From ef41ccfaa2b8110f187f587fb6b122a897741d8e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:57:58 -0400 Subject: [PATCH 15/23] refactor(mnemonic): move the instrumented SLIP-10 gate to androidDeviceTest --- .../src/{androidTest => androidDeviceTest}/assets/slip10.json | 0 .../kotlin}/com/getcode/crypt/Slip10DerivationVectorTest.kt | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename libs/encryption/mnemonic/src/{androidTest => androidDeviceTest}/assets/slip10.json (100%) rename libs/encryption/mnemonic/src/{androidTest/java => androidDeviceTest/kotlin}/com/getcode/crypt/Slip10DerivationVectorTest.kt (100%) diff --git a/libs/encryption/mnemonic/src/androidTest/assets/slip10.json b/libs/encryption/mnemonic/src/androidDeviceTest/assets/slip10.json similarity index 100% rename from libs/encryption/mnemonic/src/androidTest/assets/slip10.json rename to libs/encryption/mnemonic/src/androidDeviceTest/assets/slip10.json diff --git a/libs/encryption/mnemonic/src/androidTest/java/com/getcode/crypt/Slip10DerivationVectorTest.kt b/libs/encryption/mnemonic/src/androidDeviceTest/kotlin/com/getcode/crypt/Slip10DerivationVectorTest.kt similarity index 100% rename from libs/encryption/mnemonic/src/androidTest/java/com/getcode/crypt/Slip10DerivationVectorTest.kt rename to libs/encryption/mnemonic/src/androidDeviceTest/kotlin/com/getcode/crypt/Slip10DerivationVectorTest.kt From f97a833564f9950a898352fe647640066c6fbb8c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:59:03 -0400 Subject: [PATCH 16/23] chore(mnemonic): drop the dead Android wordlist resource --- .../mnemonic/src/main/res/raw/english.txt | 2048 ----------------- 1 file changed, 2048 deletions(-) delete mode 100644 libs/encryption/mnemonic/src/main/res/raw/english.txt diff --git a/libs/encryption/mnemonic/src/main/res/raw/english.txt b/libs/encryption/mnemonic/src/main/res/raw/english.txt deleted file mode 100644 index 942040ed50..0000000000 --- a/libs/encryption/mnemonic/src/main/res/raw/english.txt +++ /dev/null @@ -1,2048 +0,0 @@ -abandon -ability -able -about -above -absent -absorb -abstract -absurd -abuse -access -accident -account -accuse -achieve -acid -acoustic -acquire -across -act -action -actor -actress -actual -adapt -add -addict -address -adjust -admit -adult -advance -advice -aerobic -affair -afford -afraid -again -age -agent -agree -ahead -aim -air -airport -aisle -alarm -album -alcohol -alert -alien -all -alley -allow -almost -alone -alpha -already -also -alter -always -amateur -amazing -among -amount -amused -analyst -anchor -ancient -anger -angle -angry -animal -ankle -announce -annual -another -answer -antenna -antique -anxiety -any -apart -apology -appear -apple -approve -april -arch -arctic -area -arena -argue -arm -armed -armor -army -around -arrange -arrest -arrive -arrow -art -artefact -artist -artwork -ask -aspect -assault -asset -assist -assume -asthma -athlete -atom -attack -attend -attitude -attract -auction -audit -august -aunt -author -auto -autumn -average -avocado -avoid -awake -aware -away -awesome -awful -awkward -axis -baby -bachelor -bacon -badge -bag -balance -balcony -ball -bamboo -banana -banner -bar -barely -bargain -barrel -base -basic -basket -battle -beach -bean -beauty -because -become -beef -before -begin -behave -behind -believe -below -belt -bench -benefit -best -betray -better -between -beyond -bicycle -bid -bike -bind -biology -bird -birth -bitter -black -blade -blame -blanket -blast -bleak -bless -blind -blood -blossom -blouse -blue -blur -blush -board -boat -body -boil -bomb -bone -bonus -book -boost -border -boring -borrow -boss -bottom -bounce -box -boy -bracket -brain -brand -brass -brave -bread -breeze -brick -bridge -brief -bright -bring -brisk -broccoli -broken -bronze -broom -brother -brown -brush -bubble -buddy -budget -buffalo -build -bulb -bulk -bullet -bundle -bunker -burden -burger -burst -bus -business -busy -butter -buyer -buzz -cabbage -cabin -cable -cactus -cage -cake -call -calm -camera -camp -can -canal -cancel -candy -cannon -canoe -canvas -canyon -capable -capital -captain -car -carbon -card -cargo -carpet -carry -cart -case -cash -casino -castle -casual -cat -catalog -catch -category -cattle -caught -cause -caution -cave -ceiling -celery -cement -census -century -cereal -certain -chair -chalk -champion -change -chaos -chapter -charge -chase -chat -cheap -check -cheese -chef -cherry -chest -chicken -chief -child -chimney -choice -choose -chronic -chuckle -chunk -churn -cigar -cinnamon -circle -citizen -city -civil -claim -clap -clarify -claw -clay -clean -clerk -clever -click -client -cliff -climb -clinic -clip -clock -clog -close -cloth -cloud -clown -club -clump -cluster -clutch -coach -coast -coconut -code -coffee -coil -coin -collect -color -column -combine -come -comfort -comic -common -company -concert -conduct -confirm -congress -connect -consider -control -convince -cook -cool -copper -copy -coral -core -corn -correct -cost -cotton -couch -country -couple -course -cousin -cover -coyote -crack -cradle -craft -cram -crane -crash -crater -crawl -crazy -cream -credit -creek -crew -cricket -crime -crisp -critic -crop -cross -crouch -crowd -crucial -cruel -cruise -crumble -crunch -crush -cry -crystal -cube -culture -cup -cupboard -curious -current -curtain -curve -cushion -custom -cute -cycle -dad -damage -damp -dance -danger -daring -dash -daughter -dawn -day -deal -debate -debris -decade -december -decide -decline -decorate -decrease -deer -defense -define -defy -degree -delay -deliver -demand -demise -denial -dentist -deny -depart -depend -deposit -depth -deputy -derive -describe -desert -design -desk -despair -destroy -detail -detect -develop -device -devote -diagram -dial -diamond -diary -dice -diesel -diet -differ -digital -dignity -dilemma -dinner -dinosaur -direct -dirt -disagree -discover -disease -dish -dismiss -disorder -display -distance -divert -divide -divorce -dizzy -doctor -document -dog -doll -dolphin -domain -donate -donkey -donor -door -dose -double -dove -draft -dragon -drama -drastic -draw -dream -dress -drift -drill -drink -drip -drive -drop -drum -dry -duck -dumb -dune -during -dust -dutch -duty -dwarf -dynamic -eager -eagle -early -earn -earth -easily -east -easy -echo -ecology -economy -edge -edit -educate -effort -egg -eight -either -elbow -elder -electric -elegant -element -elephant -elevator -elite -else -embark -embody -embrace -emerge -emotion -employ -empower -empty -enable -enact -end -endless -endorse -enemy -energy -enforce -engage -engine -enhance -enjoy -enlist -enough -enrich -enroll -ensure -enter -entire -entry -envelope -episode -equal -equip -era -erase -erode -erosion -error -erupt -escape -essay -essence -estate -eternal -ethics -evidence -evil -evoke -evolve -exact -example -excess -exchange -excite -exclude -excuse -execute -exercise -exhaust -exhibit -exile -exist -exit -exotic -expand -expect -expire -explain -expose -express -extend -extra -eye -eyebrow -fabric -face -faculty -fade -faint -faith -fall -false -fame -family -famous -fan -fancy -fantasy -farm -fashion -fat -fatal -father -fatigue -fault -favorite -feature -february -federal -fee -feed -feel -female -fence -festival -fetch -fever -few -fiber -fiction -field -figure -file -film -filter -final -find -fine -finger -finish -fire -firm -first -fiscal -fish -fit -fitness -fix -flag -flame -flash -flat -flavor -flee -flight -flip -float -flock -floor -flower -fluid -flush -fly -foam -focus -fog -foil -fold -follow -food -foot -force -forest -forget -fork -fortune -forum -forward -fossil -foster -found -fox -fragile -frame -frequent -fresh -friend -fringe -frog -front -frost -frown -frozen -fruit -fuel -fun -funny -furnace -fury -future -gadget -gain -galaxy -gallery -game -gap -garage -garbage -garden -garlic -garment -gas -gasp -gate -gather -gauge -gaze -general -genius -genre -gentle -genuine -gesture -ghost -giant -gift -giggle -ginger -giraffe -girl -give -glad -glance -glare -glass -glide -glimpse -globe -gloom -glory -glove -glow -glue -goat -goddess -gold -good -goose -gorilla -gospel -gossip -govern -gown -grab -grace -grain -grant -grape -grass -gravity -great -green -grid -grief -grit -grocery -group -grow -grunt -guard -guess -guide -guilt -guitar -gun -gym -habit -hair -half -hammer -hamster -hand -happy -harbor -hard -harsh -harvest -hat -have -hawk -hazard -head -health -heart -heavy -hedgehog -height -hello -helmet -help -hen -hero -hidden -high -hill -hint -hip -hire -history -hobby -hockey -hold -hole -holiday -hollow -home -honey -hood -hope -horn -horror -horse -hospital -host -hotel -hour -hover -hub -huge -human -humble -humor -hundred -hungry -hunt -hurdle -hurry -hurt -husband -hybrid -ice -icon -idea -identify -idle -ignore -ill -illegal -illness -image -imitate -immense -immune -impact -impose -improve -impulse -inch -include -income -increase -index -indicate -indoor -industry -infant -inflict -inform -inhale -inherit -initial -inject -injury -inmate -inner -innocent -input -inquiry -insane -insect -inside -inspire -install -intact -interest -into -invest -invite -involve -iron -island -isolate -issue -item -ivory -jacket -jaguar -jar -jazz -jealous -jeans -jelly -jewel -job -join -joke -journey -joy -judge -juice -jump -jungle -junior -junk -just -kangaroo -keen -keep -ketchup -key -kick -kid -kidney -kind -kingdom -kiss -kit -kitchen -kite -kitten -kiwi -knee -knife -knock -know -lab -label -labor -ladder -lady -lake -lamp -language -laptop -large -later -latin -laugh -laundry -lava -law -lawn -lawsuit -layer -lazy -leader -leaf -learn -leave -lecture -left -leg -legal -legend -leisure -lemon -lend -length -lens -leopard -lesson -letter -level -liar -liberty -library -license -life -lift -light -like -limb -limit -link -lion -liquid -list -little -live -lizard -load -loan -lobster -local -lock -logic -lonely -long -loop -lottery -loud -lounge -love -loyal -lucky -luggage -lumber -lunar -lunch -luxury -lyrics -machine -mad -magic -magnet -maid -mail -main -major -make -mammal -man -manage -mandate -mango -mansion -manual -maple -marble -march -margin -marine -market -marriage -mask -mass -master -match -material -math -matrix -matter -maximum -maze -meadow -mean -measure -meat -mechanic -medal -media -melody -melt -member -memory -mention -menu -mercy -merge -merit -merry -mesh -message -metal -method -middle -midnight -milk -million -mimic -mind -minimum -minor -minute -miracle -mirror -misery -miss -mistake -mix -mixed -mixture -mobile -model -modify -mom -moment -monitor -monkey -monster -month -moon -moral -more -morning -mosquito -mother -motion -motor -mountain -mouse -move -movie -much -muffin -mule -multiply -muscle -museum -mushroom -music -must -mutual -myself -mystery -myth -naive -name -napkin -narrow -nasty -nation -nature -near -neck -need -negative -neglect -neither -nephew -nerve -nest -net -network -neutral -never -news -next -nice -night -noble -noise -nominee -noodle -normal -north -nose -notable -note -nothing -notice -novel -now -nuclear -number -nurse -nut -oak -obey -object -oblige -obscure -observe -obtain -obvious -occur -ocean -october -odor -off -offer -office -often -oil -okay -old -olive -olympic -omit -once -one -onion -online -only -open -opera -opinion -oppose -option -orange -orbit -orchard -order -ordinary -organ -orient -original -orphan -ostrich -other -outdoor -outer -output -outside -oval -oven -over -own -owner -oxygen -oyster -ozone -pact -paddle -page -pair -palace -palm -panda -panel -panic -panther -paper -parade -parent -park -parrot -party -pass -patch -path -patient -patrol -pattern -pause -pave -payment -peace -peanut -pear -peasant -pelican -pen -penalty -pencil -people -pepper -perfect -permit -person -pet -phone -photo -phrase -physical -piano -picnic -picture -piece -pig -pigeon -pill -pilot -pink -pioneer -pipe -pistol -pitch -pizza -place -planet -plastic -plate -play -please -pledge -pluck -plug -plunge -poem -poet -point -polar -pole -police -pond -pony -pool -popular -portion -position -possible -post -potato -pottery -poverty -powder -power -practice -praise -predict -prefer -prepare -present -pretty -prevent -price -pride -primary -print -priority -prison -private -prize -problem -process -produce -profit -program -project -promote -proof -property -prosper -protect -proud -provide -public -pudding -pull -pulp -pulse -pumpkin -punch -pupil -puppy -purchase -purity -purpose -purse -push -put -puzzle -pyramid -quality -quantum -quarter -question -quick -quit -quiz -quote -rabbit -raccoon -race -rack -radar -radio -rail -rain -raise -rally -ramp -ranch -random -range -rapid -rare -rate -rather -raven -raw -razor -ready -real -reason -rebel -rebuild -recall -receive -recipe -record -recycle -reduce -reflect -reform -refuse -region -regret -regular -reject -relax -release -relief -rely -remain -remember -remind -remove -render -renew -rent -reopen -repair -repeat -replace -report -require -rescue -resemble -resist -resource -response -result -retire -retreat -return -reunion -reveal -review -reward -rhythm -rib -ribbon -rice -rich -ride -ridge -rifle -right -rigid -ring -riot -ripple -risk -ritual -rival -river -road -roast -robot -robust -rocket -romance -roof -rookie -room -rose -rotate -rough -round -route -royal -rubber -rude -rug -rule -run -runway -rural -sad -saddle -sadness -safe -sail -salad -salmon -salon -salt -salute -same -sample -sand -satisfy -satoshi -sauce -sausage -save -say -scale -scan -scare -scatter -scene -scheme -school -science -scissors -scorpion -scout -scrap -screen -script -scrub -sea -search -season -seat -second -secret -section -security -seed -seek -segment -select -sell -seminar -senior -sense -sentence -series -service -session -settle -setup -seven -shadow -shaft -shallow -share -shed -shell -sheriff -shield -shift -shine -ship -shiver -shock -shoe -shoot -shop -short -shoulder -shove -shrimp -shrug -shuffle -shy -sibling -sick -side -siege -sight -sign -silent -silk -silly -silver -similar -simple -since -sing -siren -sister -situate -six -size -skate -sketch -ski -skill -skin -skirt -skull -slab -slam -sleep -slender -slice -slide -slight -slim -slogan -slot -slow -slush -small -smart -smile -smoke -smooth -snack -snake -snap -sniff -snow -soap -soccer -social -sock -soda -soft -solar -soldier -solid -solution -solve -someone -song -soon -sorry -sort -soul -sound -soup -source -south -space -spare -spatial -spawn -speak -special -speed -spell -spend -sphere -spice -spider -spike -spin -spirit -split -spoil -sponsor -spoon -sport -spot -spray -spread -spring -spy -square -squeeze -squirrel -stable -stadium -staff -stage -stairs -stamp -stand -start -state -stay -steak -steel -stem -step -stereo -stick -still -sting -stock -stomach -stone -stool -story -stove -strategy -street -strike -strong -struggle -student -stuff -stumble -style -subject -submit -subway -success -such -sudden -suffer -sugar -suggest -suit -summer -sun -sunny -sunset -super -supply -supreme -sure -surface -surge -surprise -surround -survey -suspect -sustain -swallow -swamp -swap -swarm -swear -sweet -swift -swim -swing -switch -sword -symbol -symptom -syrup -system -table -tackle -tag -tail -talent -talk -tank -tape -target -task -taste -tattoo -taxi -teach -team -tell -ten -tenant -tennis -tent -term -test -text -thank -that -theme -then -theory -there -they -thing -this -thought -three -thrive -throw -thumb -thunder -ticket -tide -tiger -tilt -timber -time -tiny -tip -tired -tissue -title -toast -tobacco -today -toddler -toe -together -toilet -token -tomato -tomorrow -tone -tongue -tonight -tool -tooth -top -topic -topple -torch -tornado -tortoise -toss -total -tourist -toward -tower -town -toy -track -trade -traffic -tragic -train -transfer -trap -trash -travel -tray -treat -tree -trend -trial -tribe -trick -trigger -trim -trip -trophy -trouble -truck -true -truly -trumpet -trust -truth -try -tube -tuition -tumble -tuna -tunnel -turkey -turn -turtle -twelve -twenty -twice -twin -twist -two -type -typical -ugly -umbrella -unable -unaware -uncle -uncover -under -undo -unfair -unfold -unhappy -uniform -unique -unit -universe -unknown -unlock -until -unusual -unveil -update -upgrade -uphold -upon -upper -upset -urban -urge -usage -use -used -useful -useless -usual -utility -vacant -vacuum -vague -valid -valley -valve -van -vanish -vapor -various -vast -vault -vehicle -velvet -vendor -venture -venue -verb -verify -version -very -vessel -veteran -viable -vibrant -vicious -victory -video -view -village -vintage -violin -virtual -virus -visa -visit -visual -vital -vivid -vocal -voice -void -volcano -volume -vote -voyage -wage -wagon -wait -walk -wall -walnut -want -warfare -warm -warrior -wash -wasp -waste -water -wave -way -wealth -weapon -wear -weasel -weather -web -wedding -weekend -weird -welcome -west -wet -whale -what -wheat -wheel -when -where -whip -whisper -wide -width -wife -wild -will -win -window -wine -wing -wink -winner -winter -wire -wisdom -wise -wish -witness -wolf -woman -wonder -wood -wool -word -work -world -worry -worth -wrap -wreck -wrestle -wrist -write -wrong -yard -year -yellow -you -young -youth -zebra -zero -zone -zoo From ecbd274a10a5c6720d5a3ec47d7c5aa27eaaf74f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 15:59:42 -0400 Subject: [PATCH 17/23] feat(shared-core): export :libs:encryption:mnemonic through SharedCore --- kmp/shared-core/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts index 641f31bca3..21dec833d7 100644 --- a/kmp/shared-core/build.gradle.kts +++ b/kmp/shared-core/build.gradle.kts @@ -36,6 +36,7 @@ kotlin { export(project(":libs:encryption:sha512")) export(project(":libs:encryption:hmac")) export(project(":libs:encryption:ed25519")) + export(project(":libs:encryption:mnemonic")) } } @@ -48,6 +49,7 @@ kotlin { api(project(":libs:encryption:sha512")) api(project(":libs:encryption:hmac")) api(project(":libs:encryption:ed25519")) + api(project(":libs:encryption:mnemonic")) } } } From 30c87a48de867b5d56a74203538d50e1ed981307 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 16:00:10 -0400 Subject: [PATCH 18/23] chore(mnemonic): exclude androidHostTest from Linux CI (JNI ed25519 dependency) --- settings.gradle.kts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 8d0c2b498d..3170afe6d0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -294,8 +294,14 @@ val kmpUnitTestModules = setOf( // parity is gated via the iOS cinterop path (macOS) instead — so it's excluded here. ":libs:encryption:utils", ) -// ed25519 excluded: its only test is a JNI host vector test that can't run on Linux CI (see kmpUnitTestModules). -val noUnitTestModules = setOf(":apps:flipcash:benchmark", ":kmp:shared-core", ":libs:encryption:ed25519") +// ed25519 and mnemonic excluded: both pull in the JNI-backed Ed25519Kmp Android actual for their +// host vector tests, which can't load on the Linux CI runner (see kmpUnitTestModules). +val noUnitTestModules = setOf( + ":apps:flipcash:benchmark", + ":kmp:shared-core", + ":libs:encryption:ed25519", + ":libs:encryption:mnemonic", +) val unitTestCandidates = includedProjectPaths.filter { path -> unitTestPaths.any { path == it || path.startsWith("$it:") } && path !in noUnitTestModules } From f0ca2e76f3320aed4e4b7cc795260c6f4fd11231 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 16:00:29 -0400 Subject: [PATCH 19/23] ci(shared-core): run the mnemonic module's iOS simulator tests --- .github/workflows/shared-core-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/shared-core-tests.yml b/.github/workflows/shared-core-tests.yml index c331a438ae..777eba4fbd 100644 --- a/.github/workflows/shared-core-tests.yml +++ b/.github/workflows/shared-core-tests.yml @@ -67,6 +67,7 @@ jobs: :libs:encryption:base58:iosSimulatorArm64Test \ :libs:encryption:ed25519:iosSimulatorArm64Test \ :libs:encryption:hmac:iosSimulatorArm64Test \ + :libs:encryption:mnemonic:iosSimulatorArm64Test \ :libs:encryption:sha256:iosSimulatorArm64Test \ :libs:encryption:sha512:iosSimulatorArm64Test From c15b0ccf3e42134f00537aaa20ed710f554831f1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 16:03:59 -0400 Subject: [PATCH 20/23] feat(shared-core): add SharedDerivation Swift facade Mirrors Ed25519.swift/Hashes.swift: wraps the Kotlin Derive/MnemonicCode objects exported through the SharedCore XCFramework, plus the [Int64] -> KotlinLongArray bridge derivedKey(seed:hardenedIndexes:) needs. Existing facade files import Foundation alongside SharedCore for Data; the plan's Derivation.swift sample omitted it, added here to match convention. --- .../spm/Sources/SharedCoreKit/Derivation.swift | 18 ++++++++++++++++++ .../SharedCoreKit/KotlinLongArray+Bridge.swift | 12 ++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/Derivation.swift create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Derivation.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Derivation.swift new file mode 100644 index 0000000000..d94fc038cd --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/Derivation.swift @@ -0,0 +1,18 @@ +import Foundation +import SharedCore + +/// BIP-39 seed derivation + SLIP-0010 ed25519 key derivation, backed by the shared Kotlin +/// implementation in `:libs:encryption:mnemonic`. +public enum SharedDerivation { + + /// Derives a 64-byte PBKDF2-SHA512 seed from a BIP-39 mnemonic phrase. + public static func seed(mnemonic: [String], passphrase: String = "") -> Data { + Data(MnemonicCode.shared.toSeed(words: mnemonic, passphrase: passphrase)) + } + + /// Derives the raw 32-byte private key by walking `hardenedIndexes` (each already offset by + /// 0x80000000) from `seed` per SLIP-0010. + public static func derivedKey(seed: Data, hardenedIndexes: [Int64]) -> Data { + Data(Derive.shared.derivedKey(seed: seed.kotlinByteArray, hardenedIndexes: hardenedIndexes.kotlinLongArray)) + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift new file mode 100644 index 0000000000..920b7e7c0e --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/KotlinLongArray+Bridge.swift @@ -0,0 +1,12 @@ +import SharedCore + +extension Array where Element == Int64 { + /// Converts this array to a `KotlinLongArray` for calling into shared Kotlin. + var kotlinLongArray: KotlinLongArray { + let array = KotlinLongArray(size: Int32(count)) + for (index, value) in enumerated() { + array.set(index: Int32(index), value: value) + } + return array + } +} From 75cd23368e853c72bafe32b4a942dc10b7364bac Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 16:30:31 -0400 Subject: [PATCH 21/23] test(shared-core): add SharedDerivation SLIP-10 cross-platform gate Verified via xcodebuild against a simulator destination, not the plan's swift test: the XCFramework has no macOS slice, so plain swift test fails with 'no library for this platform was found' (same issue the shared-core-tests.yml CI lane already works around). 1 test passed, 0 failed. --- .../SharedCoreKitTests/DerivationTests.swift | 39 +++++++++++ .../SharedCoreKitTests/Fixtures/slip10.json | 66 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/DerivationTests.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/slip10.json diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/DerivationTests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/DerivationTests.swift new file mode 100644 index 0000000000..1808bf442d --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/DerivationTests.swift @@ -0,0 +1,39 @@ +import Testing +import Foundation +@testable import SharedCoreKit + +@Suite("SharedDerivation") +struct DerivationTests { + + struct Vector: Decodable { + let name, mnemonic, passphrase, path, derivedKey, publicKey: String + } + struct Fixture: Decodable { let vectors: [Vector] } + + @Test("derivation matches the canonical SLIP-10 vectors") + func derivationMatchesCanonicalVectors() throws { + let fixture = try Fixtures.load("slip10", as: Fixture.self) + #expect(!fixture.vectors.isEmpty) + + for v in fixture.vectors { + let words = v.mnemonic.split(separator: " ").map(String.init) + let seed = SharedDerivation.seed(mnemonic: words, passphrase: v.passphrase) + + let hardenedIndexes = try v.path + .split(separator: "/") + .dropFirst() // leading "m" + .map { component -> Int64 in + let hardened = component.hasSuffix("'") + let digits = hardened ? String(component.dropLast()) : String(component) + let value = try #require(Int64(digits)) + return 0x8000_0000 + value + } + + let derivedKey = SharedDerivation.derivedKey(seed: seed, hardenedIndexes: hardenedIndexes) + #expect(derivedKey.hexString == v.derivedKey, "derivedKey mismatch for \(v.name)") + + let keyPair = SharedEd25519.keyPair(seed: derivedKey) + #expect(keyPair.publicKey.hexString == v.publicKey, "publicKey mismatch for \(v.name)") + } + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/slip10.json b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/slip10.json new file mode 100644 index 0000000000..fbb0e3331e --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/slip10.json @@ -0,0 +1,66 @@ +{ + "algorithm": "bip39+slip10-ed25519", + "note": "BIP39 seed -> SLIP-0010 ed25519 (all indices force-hardened) -> ed25519 keypair. Apps must reproduce publicKey/address for each mnemonic+path.", + "vectors": [ + { + "name": "abandon-x11-about m/44'/501'/0'/0'", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "passphrase": "", + "path": "m/44'/501'/0'/0'", + "seedBip39": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4", + "derivedKey": "37df573b3ac4ad5b522e064e25b63ea16bcbe79d449e81a0268d1047948bb445", + "publicKey": "f036276246a75b9de3349ed42b15e232f6518fc20f5fcd4f1d64e81f9bd258f7", + "address": "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk" + }, + { + "name": "abandon-x11-about m/44'/501'/0'/0'/7665'/0", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "passphrase": "", + "path": "m/44'/501'/0'/0'/7665'/0", + "seedBip39": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4", + "derivedKey": "1f317a98c39b1459a28fbc5d7e4ed9c9799dc69d194157ec7b9d645c4ed3a474", + "publicKey": "051d88bbb9c70cc1045090c3c01675620b060123f2d1f1700d8ef1f5dadcc5d8", + "address": "LyACZbC6QzPP3ixxyBjuCC1sKWuAi1bZU1xgTuFVpRD" + }, + { + "name": "abandon-x11-about m/44'/501'/0'/0'/2335'/5", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "passphrase": "", + "path": "m/44'/501'/0'/0'/2335'/5", + "seedBip39": "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4", + "derivedKey": "4b83957e1b5ca6b16e9f39fdd38c88a6a96c38e61f9b4cecb7fda43e5e249764", + "publicKey": "7bc1de0ce3459814c74dae0529df060261d896dea23264e298eb90d1d236c123", + "address": "9L6c3GSoNLLXbXAoxCgHR1fkhbX96jNaLqmQnsRYUCo4" + }, + { + "name": "legal-winner m/44'/501'/0'/0'", + "mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow", + "passphrase": "", + "path": "m/44'/501'/0'/0'", + "seedBip39": "878386efb78845b3355bd15ea4d39ef97d179cb712b77d5c12b6be415fffeffe5f377ba02bf3f8544ab800b955e51fbff09828f682052a20faa6addbbddfb096", + "derivedKey": "6987bdb06aa8a243a3019f41489ffa8e609c953a885a748d1849a8df760aa479", + "publicKey": "999d46fb3d1256f7049c8ed09314d7268612e8a91b800e91934463848305c98c", + "address": "BLeUXTx9thHGT7VJUtF9vHEmfMDgW1nnKZ9UVer2CoLX" + }, + { + "name": "legal-winner m/44'/501'/0'/0'/7665'/0", + "mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow", + "passphrase": "", + "path": "m/44'/501'/0'/0'/7665'/0", + "seedBip39": "878386efb78845b3355bd15ea4d39ef97d179cb712b77d5c12b6be415fffeffe5f377ba02bf3f8544ab800b955e51fbff09828f682052a20faa6addbbddfb096", + "derivedKey": "82c2531aae3c058eb076618c696c3d496c3249b066dd528cb83adc4f4bbe8294", + "publicKey": "9ed602ebfe2399e961e3a08535a50fd4391fdefc0341b1c790e0c4947ccd16a0", + "address": "Bh2gxt6UiDWjWkmsfK9qp46kRrjTPgvXn7DhvaPTxUr3" + }, + { + "name": "legal-winner m/44'/501'/0'/0'/2335'/5", + "mnemonic": "legal winner thank year wave sausage worth useful legal winner thank yellow", + "passphrase": "", + "path": "m/44'/501'/0'/0'/2335'/5", + "seedBip39": "878386efb78845b3355bd15ea4d39ef97d179cb712b77d5c12b6be415fffeffe5f377ba02bf3f8544ab800b955e51fbff09828f682052a20faa6addbbddfb096", + "derivedKey": "4eb3bb8632aa83691f51a4cff44021ff785df069432007bb023e858b6416f6ec", + "publicKey": "b5310f3190a4a39d5146f17072a767fb5c566593e4f2417d293d11fcd3b246f7", + "address": "DCJBY1iQroEzsNi2BMwu3zNmnB27rCV6iJHHJDAqTTmg" + } + ] +} From 4f34b7a66b192fc0bce8d0687f3caaa6f528e357 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 17:12:34 -0400 Subject: [PATCH 22/23] feat(shared-core): add macOS Kotlin/Native targets SharedCore.xcframework had no macOS slice, so FlipcashCoreVectors, a macOS SPM package and Task 22's designated regression gate, couldn't build against it. Add macosArm64/macosX64 to the 7 exported modules plus the transitively-needed :libs:encryption:utils, and add macOS(.v14) to kmp/shared-core's kmmbridge SPM config and spm/Package.swift's platform list. ed25519 needed its expect/actual pair moved from src/iosMain to src/appleMain: Kotlin's default target hierarchy only promotes a source set named appleMain to cover both iOS and macOS targets, not one named iosMain. The vendored orlp/ed25519 C source needed no changes, it compiles clean under macOS clang for both arm64 and x86_64. --- kmp/shared-core/build.gradle.kts | 3 ++- kmp/shared-core/spm/Package.swift | 3 ++- libs/codes/kikcode/build.gradle.kts | 2 ++ libs/encryption/base58/build.gradle.kts | 2 ++ libs/encryption/ed25519/build.gradle.kts | 4 ++++ .../kotlin/com/getcode/ed25519kmp/Ed25519Kmp.apple.kt} | 2 +- libs/encryption/hmac/build.gradle.kts | 2 ++ libs/encryption/mnemonic/build.gradle.kts | 2 ++ libs/encryption/sha256/build.gradle.kts | 2 ++ libs/encryption/sha512/build.gradle.kts | 2 ++ libs/encryption/utils/build.gradle.kts | 2 ++ 11 files changed, 23 insertions(+), 3 deletions(-) rename libs/encryption/ed25519/src/{iosMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.ios.kt => appleMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.apple.kt} (96%) diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts index 21dec833d7..b260f6abd4 100644 --- a/kmp/shared-core/build.gradle.kts +++ b/kmp/shared-core/build.gradle.kts @@ -25,7 +25,7 @@ kotlin { minSdk = 29 } - val appleTargets = listOf(iosArm64(), iosSimulatorArm64(), iosX64()) + val appleTargets = listOf(iosArm64(), iosSimulatorArm64(), iosX64(), macosArm64(), macosX64()) appleTargets.forEach { it.binaries.framework { baseName = "SharedCore" @@ -67,5 +67,6 @@ kmmbridge { // with a custom file it's `spm/Package.swift` that decides, so keep the two in step. spm(spmDirectory = spmPackageDir, useCustomPackageFile = true, swiftToolVersion = "5.9") { iOS { v("15") } + macOS { v("14") } } } diff --git a/kmp/shared-core/spm/Package.swift b/kmp/shared-core/spm/Package.swift index 59ea27ccca..f6214e374a 100644 --- a/kmp/shared-core/spm/Package.swift +++ b/kmp/shared-core/spm/Package.swift @@ -54,7 +54,8 @@ let binaryTarget: Target = sharedCoreLocalRoot.map { let package = Package( name: packageName, platforms: [ - .iOS(.v15) + .iOS(.v15), + .macOS(.v14), ], products: [ // The only product on purpose. Callers get Swift types; the Kotlin framework's diff --git a/libs/codes/kikcode/build.gradle.kts b/libs/codes/kikcode/build.gradle.kts index 4dc7a31f19..c108aeb864 100644 --- a/libs/codes/kikcode/build.gradle.kts +++ b/libs/codes/kikcode/build.gradle.kts @@ -21,6 +21,8 @@ kotlin { iosArm64() iosSimulatorArm64() iosX64() + macosArm64() + macosX64() sourceSets { commonMain { diff --git a/libs/encryption/base58/build.gradle.kts b/libs/encryption/base58/build.gradle.kts index fd202f1619..01b0d38805 100644 --- a/libs/encryption/base58/build.gradle.kts +++ b/libs/encryption/base58/build.gradle.kts @@ -21,6 +21,8 @@ kotlin { iosArm64() iosSimulatorArm64() iosX64() + macosArm64() + macosX64() sourceSets { commonMain { diff --git a/libs/encryption/ed25519/build.gradle.kts b/libs/encryption/ed25519/build.gradle.kts index 94c3e5b8ea..fd2ef0faea 100644 --- a/libs/encryption/ed25519/build.gradle.kts +++ b/libs/encryption/ed25519/build.gradle.kts @@ -36,6 +36,8 @@ val appleTargetDefs = listOf( AppleTarget("iosArm64", "iphoneos", "arm64"), AppleTarget("iosSimulatorArm64", "iphonesimulator", "arm64"), AppleTarget("iosX64", "iphonesimulator", "x86_64"), + AppleTarget("macosArm64", "macosx", "arm64"), + AppleTarget("macosX64", "macosx", "x86_64"), ) appleTargetDefs.forEach { target -> @@ -81,6 +83,8 @@ kotlin { iosArm64() iosSimulatorArm64() iosX64() + macosArm64() + macosX64() // ── Cinterop + linker wiring for each Apple target ──────────────────────── targets.withType().configureEach { diff --git a/libs/encryption/ed25519/src/iosMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.ios.kt b/libs/encryption/ed25519/src/appleMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.apple.kt similarity index 96% rename from libs/encryption/ed25519/src/iosMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.ios.kt rename to libs/encryption/ed25519/src/appleMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.apple.kt index c304e61b1f..46ebd8a71a 100644 --- a/libs/encryption/ed25519/src/iosMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.ios.kt +++ b/libs/encryption/ed25519/src/appleMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.apple.kt @@ -13,7 +13,7 @@ import kotlinx.cinterop.toCValues import kotlinx.cinterop.UByteVar /** - * iOS actual: calls the vendored orlp/ed25519 C library directly via Kotlin/Native cinterop. + * Apple actual (iOS + macOS): calls the vendored orlp/ed25519 C library directly via Kotlin/Native cinterop. * * Memory safety: * - All byte arrays are pinned or copied into C-managed memory inside [memScoped]. diff --git a/libs/encryption/hmac/build.gradle.kts b/libs/encryption/hmac/build.gradle.kts index e54a5aa429..9bf5f8b55c 100644 --- a/libs/encryption/hmac/build.gradle.kts +++ b/libs/encryption/hmac/build.gradle.kts @@ -14,6 +14,8 @@ kotlin { iosArm64() iosSimulatorArm64() iosX64() + macosArm64() + macosX64() sourceSets { commonMain { diff --git a/libs/encryption/mnemonic/build.gradle.kts b/libs/encryption/mnemonic/build.gradle.kts index b92b9b932c..fe1049a4f0 100644 --- a/libs/encryption/mnemonic/build.gradle.kts +++ b/libs/encryption/mnemonic/build.gradle.kts @@ -22,6 +22,8 @@ kotlin { iosArm64() iosSimulatorArm64() iosX64() + macosArm64() + macosX64() sourceSets { commonMain { diff --git a/libs/encryption/sha256/build.gradle.kts b/libs/encryption/sha256/build.gradle.kts index 7c220c0bde..b6a586a0bb 100644 --- a/libs/encryption/sha256/build.gradle.kts +++ b/libs/encryption/sha256/build.gradle.kts @@ -14,6 +14,8 @@ kotlin { iosArm64() iosSimulatorArm64() iosX64() + macosArm64() + macosX64() sourceSets { commonMain { diff --git a/libs/encryption/sha512/build.gradle.kts b/libs/encryption/sha512/build.gradle.kts index b80649b67b..cc14323851 100644 --- a/libs/encryption/sha512/build.gradle.kts +++ b/libs/encryption/sha512/build.gradle.kts @@ -14,6 +14,8 @@ kotlin { iosArm64() iosSimulatorArm64() iosX64() + macosArm64() + macosX64() sourceSets { commonMain { diff --git a/libs/encryption/utils/build.gradle.kts b/libs/encryption/utils/build.gradle.kts index ebc11adb14..b6da12b8d8 100644 --- a/libs/encryption/utils/build.gradle.kts +++ b/libs/encryption/utils/build.gradle.kts @@ -14,6 +14,8 @@ kotlin { iosArm64() iosSimulatorArm64() iosX64() + macosArm64() + macosX64() sourceSets { commonMain { From 7723dd276bf204bcb373aaef51bba5e0afc84fef Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 17:35:31 -0400 Subject: [PATCH 23/23] fix(login): use MnemonicCode.getWordList() instead of the private field Porting MnemonicCode to commonMain as an object made its backing wordList field private again (only getWordList() is exposed), breaking SeedInputViewModel's direct field access. --- .../com/flipcash/app/login/internal/SeedInputViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputViewModel.kt index 4747f7f660..9a905b8690 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputViewModel.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/SeedInputViewModel.kt @@ -62,7 +62,7 @@ internal class SeedInputViewModel @Inject constructor( if (isLoading || isSuccess) return val userWordList = wordsString.lowercase(Locale.CANADA).split(" ") - val wordCount = getValidCount(userWordList, mnemonicCode.wordList) + val wordCount = getValidCount(userWordList, mnemonicCode.getWordList()) uiFlow.update { it.copy( wordsString = wordsString,