Merge remote-tracking branch 'origin/main' into claude/zap-onchain-balance-check-gyc19z

This commit is contained in:
Claude
2026-09-02 15:10:10 +00:00
24 changed files with 858 additions and 149 deletions
+27 -1
View File
@@ -263,7 +263,33 @@ Commit the regenerated `material_symbols_outlined.ttf` alongside your
at runtime because the glyph is not in the bundled font.
Reusing a codepoint already present in `MaterialSymbols.kt` does NOT require
regenerating. See `tools/material-symbols-subset/README.md` for details and
regenerating.
### Amethyst's own icons are also a font
The icons in `commons/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are
**also** compiled into a font, `composeResources/font/amethyst_icons.ttf`, and drawn
as glyphs via `AmethystIconGlyph`. Drawing an `ImageVector` rasterises its paths into
a per-instance cached layer, so a feed re-rasterised the same glyph once per card;
a glyph is a blit from the shared text atlas. Measured: frame P90 **-10.7%**,
overrun P90 **-17.4%** on the feed scroll benchmark.
**MANDATORY:** whenever you add or change an icon under `commons/.../commons/icons/`,
regenerate the font *and* its codepoint table together:
```bash
python3 tools/icon-font/build_icon_font.py \
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \
commons/src/commonMain/composeResources/font/amethyst_icons.ttf \
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt
```
Both outputs must be committed together: codepoints are assigned in filename order,
so adding an icon renumbers the ones after it, and a stale `AmethystIcons.kt` then
points at the wrong glyph. Needs `fonttools` (`pip install fonttools`). The script
prints any icon it could not convert — an icon that is skipped must keep using its
`ImageVector`.
See `tools/material-symbols-subset/README.md` for details and
prerequisites (`pip install fonttools brotli`).
## Code Formatting
@@ -3546,6 +3546,15 @@ class Account(
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
/**
* Blocks a single relay, leaving the rest of the kind-10006 list alone.
*
* Once published, [com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient]
* strips the relay from every REQ, COUNT and publish, so the pool drops the socket as soon as
* the subscriptions that wanted it are recomputed.
*/
suspend fun blockRelay(relay: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(blockedRelayList.addRelay(relay))
/**
* Returns all known signed replaceable events that configure this account
* (profile, contact list, relay lists, mute list, bookmarks, etc.). Events
@@ -73,6 +73,20 @@ class BlockedRelayListState(
emptySet(),
)
/**
* Adds [relay] to the kind-10006 list, keeping whatever is already there.
*
* Callers that only want to block one relay (the NOTIFY prompt's "Block Relay" button, for
* instance) must not rebuild the list from a snapshot they captured earlier: the list is
* shared across clients and may have grown since. Reading the current note here keeps the
* add additive.
*/
suspend fun addRelay(relay: NormalizedRelayUrl): BlockedRelayListEvent {
val current = normalizeBlockedRelayListWithBackup(blockedListNote).toMutableList()
if (relay !in current) current.add(relay)
return saveRelayList(current)
}
suspend fun saveRelayList(blockedRelays: List<NormalizedRelayUrl>): BlockedRelayListEvent {
if (!signer.isWriteable()) throw SignerExceptions.ReadOnlyException()
val relayListForBlocked = getBlockedRelayList()
@@ -57,6 +57,26 @@ fun DisplayNotifyMessages(
accountViewModel = accountViewModel,
nav = nav,
onDismiss = { requests.dismissPaymentRequest(request) },
onBlockRelay =
if (accountViewModel.isWriteable()) {
{
accountViewModel.launchSigner {
accountViewModel.account.blockRelay(request.relayUrl)
// Reached only once the block is signed and published, because
// reportSignerErrors swallows a refused or timed-out signature without
// a toast: dismissing up front would close the dialog on a relay that
// is still unblocked and leave the user no sign anything failed. The
// prompt staying up is the feedback.
//
// Every queued prompt from the relay goes at once, not just the one on
// screen — a paid relay files one NOTIFY per rejected AUTH.
requests.dismissAllFrom(request.relayUrl)
}
}
} else {
null
},
)
}
}
@@ -30,6 +30,7 @@ import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -55,6 +56,11 @@ fun NotifyRequestDialog(
accountViewModel: AccountViewModel,
nav: INav,
onDismiss: () -> Unit,
/**
* Adds the relay that sent this message to the NIP-51 kind:10006 blocked list. Null hides the
* button — there is nothing to block for a read-only account, which cannot sign the list.
*/
onBlockRelay: (() -> Unit)? = null,
) {
AlertDialog(
onDismissRequest = onDismiss,
@@ -94,5 +100,29 @@ fun NotifyRequestDialog(
}
}
},
dismissButton =
onBlockRelay?.let {
{
TextButton(
onClick = it,
contentPadding = PaddingValues(horizontal = Size16dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Block,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Spacer(StdHorzSpacer)
Text(
text = stringRes(R.string.notify_block_relay),
color = MaterialTheme.colorScheme.error,
)
}
}
}
},
)
}
@@ -101,8 +101,15 @@ class NotifyCoordinator(
// Consume the correlation so a later, unrelated NOTIFY can't reuse a stale attribution.
// An unattributable NOTIFY (none of our auths were rejected here) is dropped rather than
// risk surfacing it under the wrong account.
val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey)
account?.relayNotifications?.addPaymentRequestIfNew(message, relay)
val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey) ?: return
// A relay the user has already blocked doesn't get to keep prompting. The pool drops the
// socket once the subscriptions that wanted this relay are recomputed, but frames already
// in flight can still arrive in that window — and the whole point of the dialog's "Block
// Relay" button is that the dialog stops coming back.
if (relay in account.blockedRelayList.flow.value) return
account.relayNotifications.addPaymentRequestIfNew(message, relay)
}
init {
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.update
@Stable
@@ -38,12 +39,12 @@ class NotifyRequestsCache {
}
fun addPaymentRequestIfNew(paymentRequest: NotifyRequest) {
if (
!this.transientPaymentRequests.value.contains(paymentRequest) &&
!this.transientPaymentRequestDismissals.value.contains(paymentRequest)
) {
this.transientPaymentRequests.value += paymentRequest
}
if (this.transientPaymentRequestDismissals.value.contains(paymentRequest)) return
// `update` rather than `value +=`: NOTIFYs are filed from the relay's socket coroutine
// while dismissals run from the UI, and a plain read-modify-write silently drops one of
// two concurrent edits — either losing a prompt or resurrecting a dismissed one.
this.transientPaymentRequests.update { if (paymentRequest in it) it else it + paymentRequest }
}
fun dismissPaymentRequest(request: NotifyRequest) {
@@ -52,4 +53,23 @@ class NotifyRequestsCache {
this.transientPaymentRequestDismissals.update { it + request }
}
}
/**
* Drops every pending prompt from [relayUrl] at once.
*
* Used when the user blocks the relay: a relay that asks for payment usually queues one NOTIFY
* per rejected AUTH, so dismissing them one at a time would keep re-showing the dialog for a
* relay the user just told us never to talk to again.
*/
fun dismissAllFrom(relayUrl: NormalizedRelayUrl) {
// getAndUpdate so the drain and the snapshot of what was drained are one atomic step: a
// NOTIFY filed by the socket coroutine between a separate read and write would otherwise
// be dropped from the pending set without ever being recorded as dismissed.
val before = this.transientPaymentRequests.getAndUpdate { pending -> pending.filterNotTo(mutableSetOf()) { it.relayUrl == relayUrl } }
val dismissed = before.filterTo(mutableSetOf()) { it.relayUrl == relayUrl }
if (dismissed.isEmpty()) return
this.transientPaymentRequestDismissals.update { it + dismissed }
}
}
@@ -43,6 +43,8 @@ import com.vitorpamplona.amethyst.commons.icons.Reposted
import com.vitorpamplona.amethyst.commons.icons.Search
import com.vitorpamplona.amethyst.commons.icons.Zap
import com.vitorpamplona.amethyst.commons.icons.following
import com.vitorpamplona.amethyst.commons.icons.symbols.AmethystIconGlyph
import com.vitorpamplona.amethyst.commons.icons.symbols.AmethystIcons
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
@@ -109,8 +111,8 @@ fun LikedIcon(
modifier: Modifier,
tint: Color = Color.Unspecified,
) {
Icon(
imageVector = Liked,
AmethystIconGlyph(
symbol = AmethystIcons.Liked,
contentDescription = stringRes(id = R.string.like_description),
modifier = modifier,
tint = tint,
@@ -135,8 +137,8 @@ fun LikeIcon(
iconSizeModifier: Modifier,
grayTint: Color,
) {
Icon(
imageVector = Like,
AmethystIconGlyph(
symbol = AmethystIcons.Like,
contentDescription = stringRes(id = R.string.like_description),
modifier = iconSizeModifier,
tint = grayTint,
@@ -148,8 +150,8 @@ fun RepostIcon(
modifier: Modifier,
tint: Color = Color.Unspecified,
) {
Icon(
imageVector = Repost,
AmethystIconGlyph(
symbol = AmethystIcons.Repost,
contentDescription = stringRes(id = R.string.boost_or_quote_description),
modifier = modifier,
tint = tint,
@@ -161,8 +163,8 @@ fun RepostedIcon(
modifier: Modifier,
tint: Color = Color.Unspecified,
) {
Icon(
imageVector = Reposted,
AmethystIconGlyph(
symbol = AmethystIcons.Reposted,
contentDescription = stringRes(id = R.string.boost_or_quote_description),
modifier = modifier,
tint = tint,
@@ -318,8 +320,8 @@ fun CommentIcon(
iconSizeModifier: Modifier,
tint: Color,
) {
Icon(
imageVector = Reply,
AmethystIconGlyph(
symbol = AmethystIcons.Reply,
contentDescription = stringRes(id = R.string.reply_description),
modifier = iconSizeModifier,
tint = tint,
@@ -350,8 +352,8 @@ fun SearchIcon(
modifier: Modifier,
tint: Color = Color.Unspecified,
) {
Icon(
imageVector = Search,
AmethystIconGlyph(
symbol = AmethystIcons.Search,
contentDescription = stringRes(id = R.string.search_button),
modifier = modifier,
tint = tint,
@@ -20,48 +20,24 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.ProfilePaymentMethod
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress
import kotlinx.coroutines.launch
/** Lightning-family target types Amethyst can pay in-app through the Send Payment screen. */
private val LIGHTNING_TARGET_TYPES = setOf("lightning", "ln", "lnurl")
@@ -97,6 +73,15 @@ fun inAppPaymentRouteFor(
}
}
/**
* The URI an external wallet app should receive for [target]: the type's own
* scheme (`bitcoin:`, `lightning:`, `https://cash.app/…`) and RFC 8905
* `payto://` for types Amethyst has no dedicated scheme for. Shared with the
* payment-target dialog so the same pill hands off to the same app wherever
* it is tapped.
*/
fun paymentTargetUri(target: PaymentTarget): String = paymentTargetStyleFor(target.type).uriFor(target.authority)
/**
* Chip for a NIP-A3 payment target. Rendered inside [DisplayPaymentRailChips]'s
* FlowRow alongside the wallet-rail chips so all payment chips share one
@@ -111,72 +96,57 @@ fun PaymentTargetChip(
) {
val style = remember(target.type) { paymentTargetStyleFor(target.type) }
val uriHandler = LocalUriHandler.current
val context = LocalContext.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val copyLabel = stringRes(R.string.copy_to_clipboard)
val copiedMessage = stringRes(R.string.copied_to_clipboard)
Surface(
shape = RoundedCornerShape(50),
color = style.color.copy(alpha = 0.10f),
border = BorderStroke(1.dp, style.color.copy(alpha = 0.35f)),
modifier =
Modifier.combinedClickable(
onClick = {
// Targets one of the user's in-app wallets can pay (lightning,
// bitcoin) go to the Send Payment screen, which collects the
// amount and pays this exact target; everything else hands off
// to an external wallet app via its payment URI.
val inAppRoute = inAppPaymentRouteFor(baseUser.pubkeyHex, target)
if (inAppRoute != null) {
nav.nav(inAppRoute)
} else {
runCatching { uriHandler.openUri(style.uriFor(target.authority)) }
.onFailure {
accountViewModel.toastManager.toast(
R.string.error_dialog_payment_error,
R.string.no_payment_app_found_for_type,
style.label,
)
}
PaymentTargetPill(
target = target,
onClick = {
// Targets one of the user's in-app wallets can pay (lightning,
// bitcoin) go to the Send Payment screen, which collects the
// amount and pays this exact target; everything else hands off
// to an external wallet app via its payment URI.
val inAppRoute = inAppPaymentRouteFor(baseUser.pubkeyHex, target)
if (inAppRoute != null) {
nav.nav(inAppRoute)
} else {
runCatching { uriHandler.openUri(style.uriFor(target.authority)) }
.onFailure {
accountViewModel.toastManager.toast(
R.string.error_dialog_payment_error,
R.string.no_payment_app_found_for_type,
style.label,
)
}
},
onLongClick = {
scope.launch {
clipboard.setText(target.authority)
Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show()
}
},
onLongClickLabel = copyLabel,
),
}
},
)
}
/**
* The pill for a single NIP-A3 payment target: the type's icon and tinted
* label followed by the shortened authority, with a long-press copy of the
* full authority. Shared by the profile's payment rail and the payment-target
* dialog so a target looks the same wherever it shows up.
*/
@Composable
fun PaymentTargetPill(
target: PaymentTarget,
onClick: () -> Unit,
) {
val style = remember(target.type) { paymentTargetStyleFor(target.type) }
ProfilePaymentChip(
color = style.color,
label = style.label,
detail = remember(target.authority) { shortAddress(target.authority) },
copyValue = target.authority,
onClick = onClick,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Icon(
symbol = style.symbol,
contentDescription = style.label,
tint = style.color,
modifier = Size16Modifier,
)
Text(
text = style.label,
color = style.color,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text = shortAddress(target.authority),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 180.dp),
)
}
Icon(
symbol = style.symbol,
contentDescription = null,
tint = style.color,
modifier = Size16Modifier,
)
}
}
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -47,11 +47,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -72,7 +71,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@Composable
@@ -153,6 +151,7 @@ fun PaymentTargetsDialog(
payInApp: ((PaymentTarget) -> Boolean)? = null,
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
var errorMessage by remember { mutableStateOf<String?>(null) }
@@ -188,15 +187,13 @@ fun PaymentTargetsDialog(
},
onPay = {
if (payInApp?.invoke(target) != true) {
try {
val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
onDismiss()
} catch (e: Exception) {
if (e is CancellationException) throw e
errorMessage = stringRes(context, R.string.no_payment_app_found)
}
// Same handoff the profile chip does: the type's own
// scheme when it has one (payto:// only as the
// fallback), and no task flags — CLEAR_TASK used to
// wipe whatever the wallet app already had open.
runCatching { uriHandler.openUri(paymentTargetUri(target)) }
.onSuccess { onDismiss() }
.onFailure { errorMessage = stringRes(context, R.string.no_payment_app_found) }
}
},
)
@@ -232,19 +229,13 @@ private fun PaymentTargetRow(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = target.type.replaceFirstChar(Char::titlecase),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = target.authority,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
// Same pill the profile page renders for this target: type icon,
// tinted type label and the shortened authority, instead of the raw
// wallet id spelled out over two lines. Tapping it pays and long-press
// copies, exactly like on the profile, so the row carries no separate
// pay button — three icon buttons left the address 0dp of width.
Box(modifier = Modifier.weight(1f)) {
PaymentTargetPill(target = target, onClick = onPay)
}
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = onShowQr) {
@@ -263,14 +254,6 @@ private fun PaymentTargetRow(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onPay) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = stringRes(R.string.payment_targets),
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -290,6 +290,10 @@ fun ProfilePaymentChip(
color = color,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
// Narrow hosts (the payment-target dialog's row) would otherwise
// wrap a long type label into a two-line pill.
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (detail != null) {
Text(
@@ -65,7 +65,7 @@ import com.halilibo.richtext.ui.resolveDefaults
import com.patrykandpatrick.vico.compose.common.VicoTheme
import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideAppIcons
import com.vitorpamplona.amethyst.model.AccentColorType
import com.vitorpamplona.amethyst.model.FontFamilyType
import com.vitorpamplona.amethyst.model.FontSizeType
@@ -738,7 +738,7 @@ fun AmethystTheme(
typography = typography,
shapes = Shapes,
content = {
ProvideMaterialSymbols {
ProvideAppIcons {
CompositionLocalProvider(
LocalDensity provides scaledDensity,
LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = resolvedFontFamily)),
+1
View File
@@ -3123,6 +3123,7 @@
</string>
<string name="payment_required_title">Message from %1$s</string>
<string name="notify_block_relay">Block Relay</string>
<string name="thread_title">Thread</string>
<string name="send_the_seller_a_message">Send the seller a message</string>
<string name="hi_seller_is_this_still_available">Hi %1$s, is this still available?</string>
@@ -0,0 +1,154 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip51Lists
import android.os.Looper
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Drives the real kind-10006 mutation behind the NOTIFY dialog's "Block Relay" button.
*
* The button hands a single relay to [BlockedRelayListState.addRelay], which read-modify-writes the
* whole list — and [BlockedRelayListEvent.updateRelayList] *replaces* every relay tag with what it
* is given. So "does blocking work" is really two questions this pins: the new relay lands, and the
* relays already blocked survive. A regression here silently wipes the user's block list on their
* next tap, which no amount of UI testing would make obvious.
*
* Each case uses a fresh keypair so its kind-10006 lives at its own address in the process-wide
* [LocalCache].
*/
class BlockedRelayListAddRelayTest {
private val paid = RelayUrlNormalizer.normalize("wss://paid.example.com")
private val alsoPaid = RelayUrlNormalizer.normalize("wss://other.example.com")
@Before
fun setup() {
// LocalCache.consume refuses the main thread; plain JVM tests have no Looper,
// where null == null reads as "main". Distinct mocks make it a worker thread.
mockkStatic(Looper::class)
every { Looper.myLooper() } returns mockk<Looper>()
every { Looper.getMainLooper() } returns mockk<Looper>()
}
@After
fun tearDown() {
unmockkStatic(Looper::class)
}
private class Fixture {
val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)
val decryptionCache = BlockedRelayListDecryptionCache(signer)
// Stubbed rather than real: AccountSettings reaches for Resources.getSystem() to read the
// user's spoken languages, which is null outside an Android runtime. The state only asks it
// for the backup list (none here) and hands it updates to persist.
val settings = mockk<AccountSettings>(relaxed = true) { every { backupBlockedRelayList } returns null }
val state =
BlockedRelayListState(
signer = signer,
cache = LocalCache,
decryptionCache = decryptionCache,
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
settings = settings,
)
/** Mirrors what the publish path does: the signed event goes into the cache it came from. */
fun land(event: BlockedRelayListEvent) = LocalCache.justConsumeMyOwnEvent(event)
suspend fun blockedIn(event: BlockedRelayListEvent) = BlockedRelayListDecryptionCache(signer).relays(event)
}
@Test
fun blockingTheFirstRelayCreatesTheList() =
runTest {
val f = Fixture()
val event = f.state.addRelay(paid)
assertEquals(BlockedRelayListEvent.KIND, event.kind)
assertEquals(setOf(paid), f.blockedIn(event))
}
/** The one that matters: a second tap must not throw away the first block. */
@Test
fun blockingASecondRelayKeepsTheFirst() =
runTest {
val f = Fixture()
f.land(f.state.addRelay(paid))
val event = f.state.addRelay(alsoPaid)
assertEquals(setOf(paid, alsoPaid), f.blockedIn(event))
}
@Test
fun blockingAnAlreadyBlockedRelayDoesNotDuplicateIt() =
runTest {
val f = Fixture()
f.land(f.state.addRelay(paid))
val event = f.state.addRelay(paid)
assertEquals(setOf(paid), f.blockedIn(event))
assertEquals(1, BlockedRelayListDecryptionCache(f.signer).relays(event).size)
}
/**
* Blocked relays are private tags. Leaking them to public tags would publish which paid relays
* the user walked away from to anyone who reads their kind-10006.
*/
@Test
fun blockedRelaysStayInEncryptedPrivateTags() =
runTest {
val f = Fixture()
f.land(f.state.addRelay(paid))
val event = f.state.addRelay(alsoPaid)
assertTrue("blocked relays must not appear in public tags", event.publicRelays().isEmpty())
assertTrue(
"no relay url may appear in the cleartext tag array",
event.tags.none { tag -> tag.any { it.contains("paid.example.com") || it.contains("other.example.com") } },
)
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class NotifyRequestsCacheTest {
private val paid = NormalizedRelayUrl("wss://paid.relay/")
private val other = NormalizedRelayUrl("wss://other.relay/")
@Test
fun blockingARelayDropsEveryPendingPromptFromIt() {
val cache = NotifyRequestsCache()
cache.addPaymentRequestIfNew("Pay up", paid)
cache.addPaymentRequestIfNew("Still unpaid", paid)
cache.addPaymentRequestIfNew("Unrelated", other)
cache.dismissAllFrom(paid)
assertEquals(
setOf(NotifyRequest(other, "Unrelated")),
cache.transientPaymentRequests.value,
)
}
@Test
fun aDismissedPromptStaysDismissedWhenTheRelayRepeatsIt() {
val cache = NotifyRequestsCache()
cache.addPaymentRequestIfNew("Pay up", paid)
cache.dismissAllFrom(paid)
cache.addPaymentRequestIfNew("Pay up", paid)
assertTrue(cache.transientPaymentRequests.value.isEmpty())
}
@Test
fun aConcurrentPromptFromTheSameRelayIsNotSilentlySwallowed() {
val cache = NotifyRequestsCache()
cache.addPaymentRequestIfNew("Pay up", paid)
// Stands in for a NOTIFY landing on the socket coroutine while the UI drains the relay:
// whatever survives the drain must still be reachable, never removed-but-unrecorded.
cache.dismissAllFrom(paid)
cache.addPaymentRequestIfNew("A different demand", paid)
val pending = cache.transientPaymentRequests.value
val dismissed = cache.transientPaymentRequestDismissals.value
assertEquals(setOf(NotifyRequest(paid, "Pay up")), dismissed)
assertEquals(setOf(NotifyRequest(paid, "A different demand")), pending)
}
@Test
fun dismissingARelayWithNoPromptsChangesNothing() {
val cache = NotifyRequestsCache()
cache.addPaymentRequestIfNew("Unrelated", other)
cache.dismissAllFrom(paid)
assertEquals(
setOf(NotifyRequest(other, "Unrelated")),
cache.transientPaymentRequests.value,
)
assertTrue(cache.transientPaymentRequestDismissals.value.isEmpty())
}
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.icons.symbols
/** Amethyst's own icons as font glyphs. See the build script for why. */
object AmethystIcons {
val Bookmark = MaterialSymbol("\uE900")
val Like = MaterialSymbol("\uE901")
val Liked = MaterialSymbol("\uE902")
val Reply = MaterialSymbol("\uE903")
val Repost = MaterialSymbol("\uE904")
val Reposted = MaterialSymbol("\uE905")
val Search = MaterialSymbol("\uE906")
val Share = MaterialSymbol("\uE907")
}
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.icons.symbols
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.amethyst_icons
import org.jetbrains.compose.resources.Font
import androidx.compose.material3.Icon as Material3Icon
/**
* Amethyst's own icons, built into a font by `tools/icon-font/build_icon_font.py`.
*
* Unlike Material Symbols this is a static font — no FILL/opsz/GRAD axes — so it needs no
* [androidx.compose.ui.text.font.FontVariation] settings. Drawing an icon as a glyph blits from
* the shared text atlas instead of rasterising an ImageVector's paths into a per-instance cached
* layer, which is what made the feed re-rasterise the same three glyphs once per card.
*/
val LocalAmethystIconsFontFamily: ProvidableCompositionLocal<FontFamily?> = staticCompositionLocalOf { null }
/**
* Builds the icon FontFamily once for the subtree and exposes it via CompositionLocal, for the
* same reason [ProvideMaterialSymbols] does: without it every call site allocates its own Font
* wrapper and breaks its own remember cache. Nest this inside ProvideMaterialSymbols at app roots.
*/
@Composable
fun ProvideAmethystIcons(content: @Composable () -> Unit) {
val font = Font(resource = Res.font.amethyst_icons)
val fontFamily = remember { FontFamily(font) }
CompositionLocalProvider(LocalAmethystIconsFontFamily provides fontFamily, content = content)
}
/**
* Draws one of Amethyst's own icons as a font glyph. Tint is baked into the painter, so
* Material3 is told not to tint again.
*/
@Composable
fun AmethystIconGlyph(
symbol: MaterialSymbol,
contentDescription: String?,
modifier: Modifier = Modifier,
tint: Color = LocalContentColor.current,
) {
Material3Icon(
painter = rememberMaterialSymbolPainter(symbol, tint, amethystIconsFontFamily()),
contentDescription = contentDescription,
modifier = modifier,
tint = Color.Unspecified,
)
}
@Composable
fun amethystIconsFontFamily(): FontFamily =
LocalAmethystIconsFontFamily.current ?: run {
val font = Font(resource = Res.font.amethyst_icons)
remember { FontFamily(font) }
}
/**
* Provides both icon fonts for a subtree: Material Symbols and Amethyst's own icons.
*
* App roots call this instead of [ProvideMaterialSymbols] so neither font is missed, and so
* adding the second one does not re-indent the whole root composable.
*/
@Composable
fun ProvideAppIcons(
weight: Int = MaterialSymbolsDefaults.WEIGHT,
content: @Composable () -> Unit,
) {
ProvideMaterialSymbols(weight = weight) {
ProvideAmethystIcons(content = content)
}
}
@@ -43,8 +43,11 @@ import androidx.compose.ui.unit.LayoutDirection
fun rememberMaterialSymbolPainter(
symbol: MaterialSymbol,
tint: Color = LocalContentColor.current,
// Lets a caller draw from a different glyph font (e.g. Amethyst's own icon font) while
// reusing this painter, its shared TextMeasurer and its caching.
family: FontFamily? = null,
): Painter {
val fontFamily = materialSymbolsFontFamily()
val fontFamily = family ?: materialSymbolsFontFamily()
val textMeasurer = materialSymbolsTextMeasurer()
val density = LocalDensity.current
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
@@ -73,12 +73,25 @@ class BlockedRelayFilteringClient(
relayList: Set<NormalizedRelayUrl>,
) {
val blocked = blockedRelays()
delegate.publish(event, if (blocked.isEmpty()) relayList else relayList - blocked)
delegate.publish(event, if (blocked.hitsNoneOf(relayList)) relayList else relayList - blocked)
}
private fun Map<NormalizedRelayUrl, List<Filter>>.withoutBlocked(): Map<NormalizedRelayUrl, List<Filter>> {
val blocked = blockedRelays()
if (blocked.isEmpty()) return this
if (blocked.hitsNoneOf(keys)) return this
return filterKeys { it !in blocked }
}
/**
* Whether none of the blocked relays appear in [targets] — i.e. whether the caller can be
* handed its own collection back untouched.
*
* Worth the extra scan because this runs on every REQ, COUNT and publish (filter assemblers
* rebuild their per-relay targets constantly) while a blocked relay is, by definition, one the
* app has stopped aiming at — so "the block list is non-empty but irrelevant to this call" is
* the overwhelmingly common case. Without the check, `filterKeys` / `minus` allocate and copy
* the whole collection every time just to reproduce it unchanged. A hash lookup per target and
* no allocation beats an allocation plus a full copy.
*/
private fun Set<NormalizedRelayUrl>.hitsNoneOf(targets: Collection<NormalizedRelayUrl>): Boolean = isEmpty() || targets.none { it in this }
}
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
class BlockedRelayFilteringClientTest {
private val good = NormalizedRelayUrl("wss://good.example/")
@@ -133,6 +134,24 @@ class BlockedRelayFilteringClientTest {
assertEquals(emptySet(), inner.publishedRelays)
}
@Test
fun aBlockListThatTouchesNothingHereIsPassedThroughWithoutCopying() {
val inner = RecordingClient()
// Non-empty block list, but none of it is aimed at on this call — the common case once the
// user has blocked anything at all.
val client = BlockedRelayFilteringClient(inner) { setOf(blocked) }
val filters = mapOf(good to listOf(Filter()), alsoGood to listOf(Filter()))
val relays = setOf(good, alsoGood)
client.subscribe("sub", filters, null)
client.publish(event(), relays)
// Identity, not just equality: reproducing an unchanged map/set costs an allocation and a
// full copy on a path that runs on every REQ and publish.
assertSame(filters, inner.subscribedFilters)
assertSame(relays, inner.publishedRelays)
}
@Test
fun blockSetIsReadPerCallSoLaterChangesApply() {
val inner = RecordingClient()
@@ -82,7 +82,7 @@ import androidx.compose.ui.window.rememberWindowState
import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideAppIcons
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings
@@ -1254,7 +1254,7 @@ private fun AppInner(
.rememberSystemDark(LocalAwtWindow.current)
com.vitorpamplona.amethyst.desktop.platform.PlatformMaterialTheme(isDark = isDark) {
ProvideMaterialSymbols(
ProvideAppIcons(
weight = com.vitorpamplona.amethyst.desktop.platform.PlatformIconWeight.current,
) {
Surface(
+46
View File
@@ -0,0 +1,46 @@
# Amethyst icon font
Builds `amethyst_icons.ttf` from the Kotlin `ImageVector` icons in
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/`.
## Why
`Icon(imageVector = …)` calls `rememberVectorPainter`, and a `VectorPainter`
rasterises its paths into a cached graphics layer **per instance**. A feed therefore
re-rasterised the same handful of glyphs once for every card scrolled in. A font glyph
is a blit from the shared text atlas instead, shared across every call site in the app
for free — no `CompositionLocal` plumbing, no per-screen scoping.
Measured on the uniform-corpus macrobenchmark (SM-T220, three arms, 0.2% noise floor):
| approach | frame P90 | overrun P90 | artwork |
|---|---|---|---|
| one shared `VectorPainter` per icon | −8.2% | −14.2% | unchanged |
| MaterialSymbols glyph substitutes | −10.4% | −16.0% | **changes** |
| **this font** | **−10.7%** | **−17.4%** | unchanged |
| ceiling: draw no icons at all | −12.7% | −22.9% | n/a |
## Usage
pip install fonttools
python3 tools/icon-font/build_icon_font.py <icons-dir> <out.ttf> <out.kt>
See the "Amethyst's own icons are also a font" section of `.claude/CLAUDE.md` for the
mandatory regeneration step and why both outputs must be committed together.
## How it works
The `ImageVector` builder DSL maps 1:1 onto SVG path commands (`moveTo` → `M`,
`curveToRelative` → `c`, …; none of the icons use `arcTo`), so the script extracts the
path data, emits an SVG `d` string, and draws it into a TrueType glyph via fontTools —
converting cubics to quadratics and flipping the y axis, since SVG grows downward and
font outlines grow upward from the baseline.
Font metrics deliberately mirror the bundled `material_symbols_outlined.ttf`
(unitsPerEm 960, ascent 1056, descent −96, advance 960) so the glyphs align with
existing MaterialSymbols call sites and `Icon()` sizing. Generated outlines land within
a few units of Google's own: our `Like` spans (78,94)–(882,851), their heart
(80,120)–(880,854).
An icon whose path data the parser cannot reach is reported and skipped rather than
silently emitted empty; it must keep using its `ImageVector`.
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Build a custom icon font from Amethyst's Kotlin ImageVector icons.
Why: drawing an ImageVector rasterises its paths into a per-instance cached layer,
so a feed re-rasterises the same glyph once per card. A font glyph is a blit from
the shared text atlas instead. Measured on the uniform-corpus macrobenchmark
(SM-T220, 0.2% noise floor): swapping the three reaction icons to font glyphs gave
frame P90 -10.4% vs -8.2% for per-icon shared painters, against a -12.7% ceiling.
This keeps Amethyst's own artwork -- it converts the existing ImageVector path data
rather than substituting Google's glyphs, so the icons look identical.
Font metrics deliberately mirror the bundled material_symbols_outlined.ttf
(unitsPerEm 960, ascent 1056, descent -96, advance 960) so the glyphs align with
existing MaterialSymbols call sites and Icon() sizing.
Usage: build_icon_font.py <icons-dir> <out.ttf> <out.kt>
"""
import re, sys, os
from fontTools.fontBuilder import FontBuilder
from fontTools.pens.ttGlyphPen import TTGlyphPen
from fontTools.pens.cu2quPen import Cu2QuPen
from fontTools.pens.transformPen import TransformPen
from fontTools.misc.transform import Transform
from fontTools.svgLib.path.parser import parse_path
UPEM, ASCENT, DESCENT, ADVANCE = 960, 1056, -96, 960
FIRST_CODEPOINT = 0xE900
MAX_ERR = 1.0 # cubic->quadratic tolerance, in font units
# ImageVector DSL -> SVG path command. No arcTo: none of the icons use one.
CMDS = {
"moveTo": "M", "moveToRelative": "m",
"lineTo": "L", "lineToRelative": "l",
"horizontalLineTo": "H", "horizontalLineToRelative": "h",
"verticalLineTo": "V", "verticalLineToRelative": "v",
"curveTo": "C", "curveToRelative": "c",
"reflectiveCurveTo": "S", "reflectiveCurveToRelative": "s",
"quadTo": "Q", "quadToRelative": "q",
"reflectiveQuadTo": "T", "reflectiveQuadToRelative": "t",
"close": "Z",
}
CALL_RE = re.compile(r"\b(" + "|".join(CMDS) + r")\(([^()]*)\)")
NUM_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?")
STROKE_W_RE = re.compile(r"strokeLineWidth\s*=\s*([\d.]+)f?")
def is_stroked(src: str) -> bool:
"""True if the icon paints a stroke, not just a fill.
A glyph outline is filled: there is no pen width in a TrueType glyph. Converting a
stroked icon would emit only its fill and silently change the artwork -- Zap is drawn
as a thin outline (strokeLineWidth 1.2) and came out as a solid bolt. Such icons must
keep their ImageVector.
"""
if any(float(w) > 0 for w in STROKE_W_RE.findall(src)):
return True
return "stroke = SolidColor" in src
def kotlin_to_svg_path(src: str):
"""Extract viewport and an SVG 'd' string from one ImageVector .kt file."""
vw = re.search(r"viewportWidth\s*=\s*([\d.]+)f?", src)
vh = re.search(r"viewportHeight\s*=\s*([\d.]+)f?", src)
if vw and vh:
viewport = (float(vw.group(1)), float(vh.group(1)))
elif "materialIcon(" in src:
# materialIcon() sets the viewport itself; Material's convention is 24x24.
viewport = (24.0, 24.0)
else:
return None, None
# Only look inside the vector builder, never the @Preview composable above it.
# Only look inside the vector builder, never the @Preview composable above it, and never
# a helper like materialOutlinedPath() declared after it.
start = src.find(".apply {")
if start == -1:
start = src.find("materialIcon(")
body = src[start:] if start != -1 else src
end = body.find("\ninline fun ")
if end != -1:
body = body[:end]
parts = []
for m in CALL_RE.finditer(body):
cmd, args = m.group(1), m.group(2)
letter = CMDS[cmd]
if cmd == "close":
parts.append("Z")
continue
nums = NUM_RE.findall(args)
if not nums:
continue
parts.append(letter + " " + " ".join(nums))
return viewport, " ".join(parts)
def build_glyph(d: str, viewport):
vw, vh = viewport
# Uniform scale on the larger axis keeps non-square viewports undistorted.
s = UPEM / max(vw, vh)
pen = TTGlyphPen(None)
# y flips: SVG grows downward, font outlines grow upward from the baseline.
tp = TransformPen(Cu2QuPen(pen, MAX_ERR), Transform(s, 0, 0, -s, 0, vh * s))
parse_path(d, tp)
return pen.glyph()
def main(icons_dir, out_ttf, out_kt):
files = sorted(f for f in os.listdir(icons_dir) if f.endswith(".kt"))
glyphs, cmap, names, skipped = {".notdef": TTGlyphPen(None).glyph()}, {}, [".notdef"], []
cp = FIRST_CODEPOINT
for fn in files:
name = fn[:-3]
src = open(os.path.join(icons_dir, fn), encoding="utf-8").read()
if is_stroked(src):
skipped.append((name, "draws a stroke; a glyph can only be filled"))
continue
viewport, d = kotlin_to_svg_path(src)
if not d:
skipped.append((name, "no path data"))
continue
try:
glyphs[name] = build_glyph(d, viewport)
except Exception as e: # noqa: BLE001 - report and continue, don't kill the build
skipped.append((name, f"{type(e).__name__}: {e}"))
continue
cmap[cp] = name
names.append(name)
print(f" {name:<12} U+{cp:04X} viewport {viewport[0]:g}x{viewport[1]:g} {len(d)} chars")
cp += 1
fb = FontBuilder(UPEM, isTTF=True)
fb.setupGlyphOrder(names)
fb.setupCharacterMap(cmap)
fb.setupGlyf(glyphs)
fb.setupHorizontalMetrics({n: (ADVANCE, 0) for n in names})
fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT)
fb.setupNameTable({
"familyName": "Amethyst Icons", "styleName": "Regular",
"psName": "AmethystIcons-Regular", "version": "1.0",
})
fb.setupOS2(sTypoAscender=ASCENT, sTypoDescender=DESCENT,
usWinAscent=ASCENT, usWinDescent=abs(DESCENT))
fb.setupPost(keepGlyphNames=False)
fb.save(out_ttf)
with open(out_kt, "w", encoding="utf-8") as fh:
fh.write("// GENERATED by tools/icon-font/build_icon_font.py -- do not edit by hand.\n")
fh.write("package com.vitorpamplona.amethyst.commons.icons.symbols\n\n")
fh.write("/** Amethyst's own icons as font glyphs. See the build script for why. */\n")
fh.write("object AmethystIcons {\n")
for code, name in sorted(cmap.items()):
fh.write(f' val {name} = MaterialSymbol("\\u{code:04X}")\n')
fh.write("}\n")
print(f"\nwrote {out_ttf} ({os.path.getsize(out_ttf)} bytes), {len(cmap)} glyphs")
for name, why in skipped:
print(f" SKIPPED {name}: {why}")
if __name__ == "__main__":
if len(sys.argv) != 4:
sys.exit(__doc__)
main(*sys.argv[1:])