Compare commits

...
Author SHA1 Message Date
Vitor Pamplona c49b47f99b probe: swap the feed avatar's SubcomposeAsyncImage for AsyncImage 2026-09-02 10:56:46 -04:00
Vitor PamplonaandClaude Opus 5 3845c52853 test(macrobenchmark): sync icon font to the shipped 8-glyph build
Replication arm measured the exact artifact that ships: the build script now skips
stroked icons (Following, Zap, ZapSplit), whose fill-only conversion changed the
artwork. frame P90 -10.7% / -10.4% across two runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 10:21:25 -04:00
Vitor PamplonaandClaude Opus 5 6b2df0fb5b test(macrobenchmark): probes for glyph-swap and a custom Amethyst icon font
Two measurement probes for the reaction row's dominant cost, vector-path icon
rasterisation (frame P90 -12.7% if the icons are not drawn at all):

  PROBE_RX_GLYPH_SWAP  - substitutes MaterialSymbols Chat/Sync/Favorite
  PROBE_RX_CUSTOM_FONT - draws Amethyst's OWN artwork from a generated icon font

Adds tools/icon-font/build_icon_font.py, which parses the Kotlin ImageVector DSL
into SVG paths and builds a TTF with fontTools. Metrics copy the bundled Material
Symbols font (upem 960, ascent 1056, descent -96) so glyphs align with existing
call sites.

Measured against clipA/clipB baselines, 0.2% noise floor, gate perfect:

  shared VectorPainters   frame P90  -8.2%   overrun P90 -14.2%   art unchanged
  MaterialSymbols swap    frame P90 -10.4%   overrun P90 -16.0%   art CHANGES
  custom Amethyst font    frame P90 -10.7%   overrun P90 -17.4%   art unchanged
  ceiling (no icons)      frame P90 -12.7%   overrun P90 -22.9%

rememberMaterialSymbolPainter takes an optional family so a caller can draw from
the icon font while reusing the painter and its shared TextMeasurer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 09:38:42 -04:00
Vitor PamplonaandClaude Opus 5 2243de5d2d test(macrobenchmark): add uniform corpus, cutting the noise floor 10.1% -> 1.4%
Arms were not comparable. The app persists no events, so each launch re-downloads
and renders a different subset of notes in a different order -- two cold launches
shared only half their visible text, and a longer settle diverged further. With
notes of very different heights, a fixed-distance scroll then crossed a different
number of cards per arm (14 vs 17), which buried every effect worth shipping.

seed-uniform.sh serves same-height notes so the scroll crosses a constant number
of cards regardless of what loaded. On two arms of identical code that takes
NoteCardSumCount from 14-vs-17 to 18-vs-18, frame P90 drift from 10.1% to 1.4%,
and overrun P90 drift from 20.0% to 2.8%.

Also cut the radios before launch rather than after the feed is built, so the app
can only ever see the corpus; the relay arrives over adb reverse, which is USB and
unaffected by the radios.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-02 00:57:37 -04:00
Vitor PamplonaandClaude Opus 5 a50172bb38 test(macrobenchmark): add PROBE_CIRCLE_CROP avatar probe
Measurement scaffolding only, no shipped behaviour change. Bakes the circle into
the decoded bitmap with Coil's CircleCropTransformation and drops the per-frame
outline clip, to test whether the measured cost of Modifier.clip(CircleShape) on
feed avatars can be recovered in a shippable form.

Result so far: it does NOT replicate. One run showed DrawAuthor -39.4% against a
2.8% drift floor; a second showed +10.2% with the identical baselines 52.7% apart.
Keeping the probe so the experiment can be repeated once the rig renders a
deterministic set of notes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-01 22:11:01 -04:00
Vitor PamplonaandClaude Opus 5 86e0824581 test(macrobenchmark): fix rt_analyze query flag and identify threads by slice
trace_processor's -q takes a query *file*; inline SQL needs -Q, so every query in
the script silently failed with "unable to read file".

Also stop matching RenderThread by thread name. A lightweight
`perfetto -a <pkg> <categories>` capture omits the process-tree data source, so
thread.name is NULL and a name match returns nothing. Identify RenderThread by the
thread that owns the DrawFrame slices instead, which works either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-01 20:00:42 -04:00
Vitor PamplonaandClaude Opus 5 cac8fb9e19 test(macrobenchmark): warm image cache and normalize section metrics by count
Two rig defects made A/B runs on the real-event corpus unreadable.

Card heights vary on a corpus of real notes, so a fixed-distance swipe crosses a
different number of cards each iteration (NoteCardCount was 10/13/8 across three
arms, two of them identical code). TraceSectionMetric(Mode.Sum) sums over that
varying denominator, so summed metrics drifted 36-73% between identical arms.
Add tools/normalize.py to compare per-occurrence times instead; on the motivating
run it took DrawAuthor from a 41.8% apparent swing to a 4.6% drift floor.

Remote images also resolve asynchronously, so heights keep moving into the
measured window. Scroll the whole corpus once with the network still up before
cutting the radios.

Also add tools/rt_analyze.sh, which decomposes a trace with Perfetto's
trace_processor (self-time by slice, atlas uploads, children of `animation`)
rather than parsing atrace text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-01 19:54:18 -04:00
Vitor Pamplona b7ae068141 chore(bench): add the fixed-corpus rig, methodology notes, and correct the release-footprint claim
Follow-up to the measurement rig commit.

- tools/feed-bench-corpus/setup.sh — serves a frozen event corpus from a local
  geode relay so two runs render identical notes. Benchmark deltas measured
  against a live feed are not comparable: two identical baseline runs produced
  DrawReactions of 2.78 and 1.70, and AuthorImages differing by 73%, purely from
  the firehose serving different content to each arm.

  It also records that a *synthetic* corpus is the wrong tool for anything
  involving engagement counts — with every reaction count at zero the
  AnimatedContent under test is never constructed, so the measurement silently
  tests nothing. Capture real events instead, reactions first and then the notes
  they e-tag, since freshly fetched notes have no engagement yet.

- macrobenchmark/README.md — how to run it, and the traps that cost the most
  time: a tight within-arm spread says nothing about a between-arm delta;
  check the controls before reading the result; prove the change under test is
  actually active before believing "no effect"; don't drive the device while a
  run is in flight.

- Corrects the claim in NoteRenderTrace's KDoc that R8 deletes the markers from
  release. It does not. The R8'd release DEX still contains the marker strings,
  the tracer classes and an androidx.tracing.Trace reference — verified by
  grepping it, with the benchmark APK as a positive control for the method. The
  guarded branches never execute, so runtime cost is nil, but the footprint is
  not zero and this must be moved behind a source-set split before it could land
  on main.
2026-09-01 17:56:22 -04:00
Vitor Pamplona 8f75cf08f8 chore(bench): feed render measurement rig — macrobenchmark, tracers, ablation probes
Measurement scaffolding used to study NoteCompose rendering on an SM-T220 and a
Pixel 9 AVD. Kept off the fix branch on purpose: several probes are broken by
design and exist only to be measured.

- :macrobenchmark — FeedScrollBenchmark with scrollLive / scrollQuiet /
  scrollAttribution / scrollPhases. Raw coordinate swipes rather than
  UiObject2.scroll (which throws StaleObjectException when the list recycles a
  node mid-gesture). Requires -Pandroid.injected.androidTest.leaveApksInstalled
  AfterRun=true, without which AGP uninstalls the app and deletes its account.
- TracedComposition / Modifier.tracedDraw — composition and draw markers behind
  BuildConfig.TRACE_NOTE_RENDER, true only in the benchmark build type so R8
  deletes them from debug and release.
- Ablation probes (all default false): flow->state subscriptions, reaction row
  animations, clickable/ripple, lazy ripple, counters, produceState, icons split
  by vector vs glyph, avatar clip.
2026-09-01 10:05:57 -04:00
32 changed files with 2541 additions and 303 deletions
+162
View File
@@ -88,6 +88,156 @@ android {
buildConfigField("String", "RELEASE_NOTES_ID", "\"00d306e01792e48b93638b73b57a7eb8b89622a338b1b4f529622150d46cd710\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Measurement probe: see service/relayClient/reqCommand/ProbedCollect.kt. Off unless
// -PprobeNoFlowState=true is passed, so normal builds constant-fold it away.
buildConfigField(
"boolean",
"PROBE_NO_FLOW_STATE",
(project.findProperty("probeNoFlowState")?.toString() ?: "false"),
)
// Measurement probe: forces the reaction row's Crossfade / AnimatedContent wrappers
// onto their existing non-animated branch, so the cost of building the transition
// machinery per card can be measured. Off unless -PprobeNoRxAnimations=true.
buildConfigField(
"boolean",
"PROBE_NO_RX_ANIMATIONS",
(project.findProperty("probeNoRxAnimations")?.toString() ?: "false"),
)
// Measurement probe: strips the reaction row's ClickableBox down to a plain Box, so the
// cost of `clickable` + its eagerly-created MutableInteractionSource and ripple node can
// be bounded. Off unless -PprobeNoRxClickable=true.
buildConfigField(
"boolean",
"PROBE_NO_RX_CLICKABLE",
(project.findProperty("probeNoRxClickable")?.toString() ?: "false"),
)
// Candidate fix under measurement: keeps the reaction row's click, ripple and semantics
// but lets Compose build the interaction source and ripple node lazily on first touch.
// Off unless -PprobeLazyRxRipple=true.
buildConfigField(
"boolean",
"PROBE_LAZY_RX_RIPPLE",
(project.findProperty("probeLazyRxRipple")?.toString() ?: "false"),
)
// Candidate fix under measurement: defers building Crossfade/AnimatedContent transitions
// until a value actually changes, so scrolling a card in costs nothing for animations
// that never play. Keeps the animations. -PfixLazyAnim=true.
buildConfigField(
"boolean",
"FIX_LAZY_ANIM",
(project.findProperty("fixLazyAnim")?.toString() ?: "false"),
)
// Measurement probe: replaces the reaction row's counter Text with a same-width Spacer,
// isolating the cost of text shaping from the rest of the button. Off unless
// -PprobeNoRxCounters=true.
buildConfigField(
"boolean",
"PROBE_NO_RX_COUNTERS",
(project.findProperty("probeNoRxCounters")?.toString() ?: "false"),
)
// Measurement probe: replaces the reaction row's produceState calls with a plain snapshot
// state holding the same initial value, so the coroutine each one launches per card is
// never started. Off unless -PprobeNoRxProduceState=true.
buildConfigField(
"boolean",
"PROBE_NO_RX_PRODUCE_STATE",
(project.findProperty("probeNoRxProduceState")?.toString() ?: "false"),
)
// Measurement probe: replaces the reaction row's icons with same-size Spacers, isolating
// vector-path and glyph rasterisation from the rest of the row's draw cost.
// Off unless -PprobeNoRxIcons=true.
buildConfigField(
"boolean",
"PROBE_NO_RX_ICONS",
(project.findProperty("probeNoRxIcons")?.toString() ?: "false"),
)
// Measurement probe: draws the three reaction icons from Amethyst's OWN artwork
// converted to a font by tools/icon-font/build_icon_font.py. Same pixels as today,
// but blitted from the text atlas instead of rasterised per card.
// -PprobeRxCustomFont=true.
buildConfigField(
"boolean",
"PROBE_RX_CUSTOM_FONT",
(project.findProperty("probeRxCustomFont")?.toString() ?: "false"),
)
// Measurement probe: draws the three reaction icons (Like/Reply/Reposted) as
// MaterialSymbols font glyphs instead of ImageVector paths, to price the conversion.
// The ablation says vector rasterisation is the reaction row's cost (frame P90 -12.7%
// for 3 icons) while the whole glyph set costs -1.6%; this measures what is actually
// recovered by swapping, since a glyph is not free. Uses the closest available glyphs
// (Chat / Sync / Favorite) -- appearance differs, so this prices the change, it does
// not propose it. -PprobeRxGlyphSwap=true.
buildConfigField(
"boolean",
"PROBE_RX_GLYPH_SWAP",
(project.findProperty("probeRxGlyphSwap")?.toString() ?: "false"),
)
// Candidate fix under measurement: shares one VectorPainter per reaction icon across every
// card in the feed instead of one per card. -PfixSharedIcons=true.
buildConfigField(
"boolean",
"FIX_SHARED_ICONS",
(project.findProperty("fixSharedIcons")?.toString() ?: "false"),
)
// Splits the icon ablation: which half of the reaction row's icon draw cost is vector-path
// rasterisation (Like/Reply/Reposted) and which is MaterialSymbols font glyphs (Bolt,
// Share, ExpandLess/More, Mic). -PprobeNoRxVectorIcons / -PprobeNoRxGlyphIcons.
buildConfigField(
"boolean",
"PROBE_NO_RX_VECTOR_ICONS",
(project.findProperty("probeNoRxVectorIcons")?.toString() ?: "false"),
)
buildConfigField(
"boolean",
"PROBE_NO_RX_GLYPH_ICONS",
(project.findProperty("probeNoRxGlyphIcons")?.toString() ?: "false"),
)
// Measurement probe: bakes the circle into the decoded bitmap with Coil's
// CircleCropTransformation and drops the per-frame outline clip. This is the
// *shippable* form of the PROBE_NO_AVATAR_CLIP win (frame P90 -6.2%): the circle
// is applied once at decode and cached, rather than clipped on every frame.
// Risk being measured: Coil cannot transform hardware bitmaps, so this may force
// software bitmaps and cost more in drawing than the clip saved.
// -PprobeCircleCrop=true.
buildConfigField(
"boolean",
"PROBE_CIRCLE_CROP",
(project.findProperty("probeCircleCrop")?.toString() ?: "false"),
)
// Measurement probe: swaps the feed avatar's SubcomposeAsyncImage for a plain AsyncImage.
// Coil documents SubcomposeAsyncImage as the slower option, but MEASURED IT IS NOT: the
// swap is a regression, DrawAuthor +74% per occurrence against a 4.6% drift floor, because
// AsyncImagePainter keeps the placeholder/error/fallback painters live and draws through
// more indirection than the resolved Image subcomposition settles into. Kept so the
// result can be re-verified; do not "fix" the feed by taking the plain path.
buildConfigField(
"boolean",
"PROBE_NO_SUBCOMPOSE_AVATAR",
(project.findProperty("probeNoSubcomposeAvatar")?.toString() ?: "false"),
)
// Measurement probe: drops the circular clip from feed avatars, so the RenderThread cost of
// the per-avatar graphics layer + outline clip can be separated from decoding and drawing
// the image itself. Probe builds show square avatars. -PprobeNoAvatarClip=true.
buildConfigField(
"boolean",
"PROBE_NO_AVATAR_CLIP",
(project.findProperty("probeNoAvatarClip")?.toString() ?: "false"),
)
vectorDrawables {
useSupportLibrary = true
}
@@ -210,11 +360,15 @@ android {
getByName("release") {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
isMinifyEnabled = !skipMapping
// See TRACE_NOTE_RENDER on the benchmark type below. Constant-false here, so
// R8 deletes every feed trace marker from the shipped build.
buildConfigField("boolean", "TRACE_NOTE_RENDER", "false")
}
getByName("debug") {
applicationIdSuffix = ".debug"
versionNameSuffix = "-DEBUG"
resValue("string", "app_name", "@string/app_name_debug")
buildConfigField("boolean", "TRACE_NOTE_RENDER", "false")
}
create("benchmark") {
initWith(getByName("release"))
@@ -223,6 +377,10 @@ android {
resValue("string", "app_name", "@string/app_name_benchmark")
isProfileable = true
signingConfig = signingConfigs.getByName("debug")
// Emits atrace sections around each part of the feed's note card, so
// :macrobenchmark's TraceSectionMetric can attribute composition time
// per sub-component. Benchmark-only: see ui/note/NoteRenderTrace.kt.
buildConfigField("boolean", "TRACE_NOTE_RENDER", "true")
}
}
@@ -395,6 +553,10 @@ dependencies {
// Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source:
// adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \
// -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver
// atrace sections for the feed's note card (ui/note/NoteRenderTrace.kt). Compiled in
// everywhere, but constant-folded out of debug/release by BuildConfig.TRACE_NOTE_RENDER.
implementation(libs.androidx.tracing)
debugImplementation("androidx.compose.runtime:runtime-tracing")
debugImplementation("androidx.tracing:tracing-perfetto:1.0.1")
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.1")
@@ -0,0 +1,121 @@
/*
* 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.ui.note
import androidx.compose.animation.core.tween
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The feed's animated elements defer building their `Transition` until a value actually changes,
* because first composition has nothing to animate and building one per card per scroll is pure
* waste (measured: roughly half the composition cost of every reaction-row button).
*
* The whole point of deferring rather than removing is that the animation must still play. These
* tests pin that: they drive the clock manually and assert that the **first** change — the one that
* happens right after the transition is lazily created — still shows outgoing and incoming content
* simultaneously, which only a running animation does. A regression that turned the deferral into a
* plain snap would show exactly one of them and fail here.
*/
@RunWith(AndroidJUnit4::class)
class DeferredAnimationTest {
@get:Rule
val rule = createComposeRule()
@Test
fun deferredCrossfadeStillAnimatesTheFirstChange() {
val state = mutableStateOf("A")
rule.mainClock.autoAdvance = false
rule.setContent {
DeferredCrossfade(
targetState = state.value,
modifier = Modifier,
contentAlignment = Alignment.TopStart,
animationSpec = tween(DURATION_MS),
label = "test",
) { value ->
Text(value, modifier = Modifier.testTag("text_$value"))
}
}
// Before any change the transition has not been built, and only the current value renders.
rule.onNodeWithTag("text_A").assertIsDisplayed()
rule.onNodeWithTag("text_B").assertDoesNotExist()
state.value = "B"
rule.mainClock.advanceTimeByFrame()
rule.mainClock.advanceTimeBy(DURATION_MS / 3L)
// Mid-crossfade both are in the tree. This is the assertion that a snap would fail.
rule.onNodeWithTag("text_A").assertExists()
rule.onNodeWithTag("text_B").assertExists()
rule.mainClock.advanceTimeBy(DURATION_MS * 3L)
rule.onNodeWithTag("text_B").assertIsDisplayed()
rule.onNodeWithTag("text_A").assertDoesNotExist()
}
@Test
fun deferredAnimatedContentStillAnimatesTheFirstChange() {
val count = mutableStateOf(1)
rule.mainClock.autoAdvance = false
rule.setContent {
DeferredAnimatedContent(targetState = count.value, label = "test") { value ->
Text("$value", modifier = Modifier.testTag("count_$value"))
}
}
rule.onNodeWithTag("count_1").assertIsDisplayed()
rule.onNodeWithTag("count_2").assertDoesNotExist()
count.value = 2
rule.mainClock.advanceTimeByFrame()
rule.mainClock.advanceTimeBy(SLIDE_MS / 3L)
// The sliding counter keeps the outgoing number on screen while the new one slides in.
rule.onNodeWithTag("count_1").assertExists()
rule.onNodeWithTag("count_2").assertExists()
rule.mainClock.advanceTimeBy(SLIDE_MS * 5L)
rule.onNodeWithTag("count_2").assertIsDisplayed()
rule.onNodeWithTag("count_1").assertDoesNotExist()
}
companion object {
const val DURATION_MS = 300
/** `slideAnimation` in ReactionsRow uses a 100 ms tween. */
const val SLIDE_MS = 100
}
}
@@ -0,0 +1,70 @@
/*
* 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.reqCommand
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/**
* **Measurement probe — not a feature.**
*
* A feed card subscribes to dozens of `LocalCache` flows, and every subscription costs a
* `collectAsStateWithLifecycle`: a `LifecycleEventObserver` allocated and registered, plus a
* coroutine launched to run `repeatOnLifecycle`. With ~30 of those per note and ~13 new notes
* composed per scroll, the question is how much of the card's composition time is the
* *subscription machinery* rather than the UI it feeds.
*
* When `PROBE_NO_FLOW_STATE` is on, these return a plain snapshot state holding exactly the
* value the real collector would have shown at first composition — so the card renders the
* same pixels on the way in — and simply never subscribe. The delta between a probe build and
* a normal one is the cost of flow→state creation.
*
* A probe build is **broken on purpose**: nothing live updates any more (no incoming reaction,
* zap, boost or reply count ever moves). It exists to be measured and thrown away. The flag
* defaults to `false`, so ordinary builds are byte-for-byte unaffected — the constant folds and
* R8 drops the branch.
*
* Enable with:
* ```
* ./gradlew … -PprobeNoFlowState=true
* ```
*/
@Composable
fun <T> StateFlow<T>.collectAsStateProbed(): State<T> =
if (BuildConfig.PROBE_NO_FLOW_STATE) {
remember(this) { mutableStateOf(value) }
} else {
collectAsStateWithLifecycle()
}
@Composable
fun <T> Flow<T>.collectAsStateProbed(initial: T): State<T> =
if (BuildConfig.PROBE_NO_FLOW_STATE) {
remember(this) { mutableStateOf(initial) }
} else {
collectAsStateWithLifecycle(initial)
}
@@ -24,11 +24,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.textNoteModifications
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.collectAsStateProbed
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.isMinichatReply
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -56,7 +56,7 @@ fun observeNote(
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().metadata.stateFlow }
return flow.collectAsStateWithLifecycle()
return flow.collectAsStateProbed()
}
@Suppress("UNCHECKED_CAST")
@@ -78,7 +78,7 @@ fun <T : Event> observeNoteEvent(
.mapLatest { it.note.event as? T? }
}
return flow.collectAsStateWithLifecycle(note.event as? T?)
return flow.collectAsStateProbed(note.event as? T?)
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -102,7 +102,7 @@ fun <T> observeNoteAndMap(
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(map(note))
return flow.collectAsStateProbed(map(note))
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -128,7 +128,7 @@ fun <T, U> observeNoteEventAndMapNotNull(
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle((note.event as? T)?.let { map(it) })
return flow.collectAsStateProbed((note.event as? T)?.let { map(it) })
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -154,7 +154,7 @@ fun <T, U> observeNoteEventAndMap(
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(map(note.event as? T))
return flow.collectAsStateProbed(map(note.event as? T))
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -176,7 +176,7 @@ fun observeNoteHasEvent(
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.event != null)
return flow.collectAsStateProbed(note.event != null)
}
@Composable
@@ -189,7 +189,7 @@ fun observeNoteReplies(
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().replies.stateFlow }
return flow.collectAsStateWithLifecycle()
return flow.collectAsStateProbed()
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -212,7 +212,7 @@ fun observeNoteReplyCount(
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.replies.size)
return flow.collectAsStateProbed(note.replies.size)
}
/**
@@ -244,7 +244,7 @@ fun observeNoteMinichatReplyCount(
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.replies.count { isMinichatReply(it.event) })
return flow.collectAsStateProbed(note.replies.count { isMinichatReply(it.event) })
}
@Composable
@@ -257,7 +257,7 @@ fun observeNoteReactions(
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().reactions.stateFlow }
return flow.collectAsStateWithLifecycle()
return flow.collectAsStateProbed()
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -282,7 +282,7 @@ fun observeNoteReactionCount(
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(note.countReactions())
return flow.collectAsStateProbed(note.countReactions())
}
@Composable
@@ -295,7 +295,7 @@ fun observeNoteZaps(
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().zaps.stateFlow }
return flow.collectAsStateWithLifecycle()
return flow.collectAsStateProbed()
}
@Composable
@@ -308,7 +308,7 @@ fun observeNoteReposts(
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().boosts.stateFlow }
return flow.collectAsStateWithLifecycle()
return flow.collectAsStateProbed()
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -332,7 +332,7 @@ fun observeNoteRepostsBy(
.flowOn(Dispatchers.IO)
}
return flow.collectAsStateWithLifecycle(note.isBoostedBy(user))
return flow.collectAsStateProbed(note.isBoostedBy(user))
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -355,7 +355,7 @@ fun observeNoteRepostCount(
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.boosts.size)
return flow.collectAsStateProbed(note.boosts.size)
}
@Composable
@@ -381,7 +381,7 @@ fun observeNoteReferences(
}.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(note.hasZapsBoostsOrReactions())
return flow.collectAsStateProbed(note.hasZapsBoostsOrReactions())
}
@Composable
@@ -394,7 +394,7 @@ fun observeNoteOts(
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().ots.stateFlow }
return flow.collectAsStateWithLifecycle()
return flow.collectAsStateProbed()
}
// Resolves the actual modification list off the main thread and filters identical results,
@@ -468,5 +468,5 @@ fun observeCommunityApprovalNeedStatus(
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(false)
return flow.collectAsStateProbed(false)
}
@@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.ui.actions
import androidx.collection.mutableScatterMapOf
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.Box
@@ -37,6 +39,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.util.fastForEach
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -53,11 +56,58 @@ fun <T> CrossfadeIfEnabled(
Box(modifier, contentAlignment) {
content(targetState)
}
} else if (BuildConfig.FIX_LAZY_ANIM) {
DeferredCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
} else {
MyCrossfade(targetState, modifier, contentAlignment, animationSpec, label, content)
}
}
/** Latches the first time a crossfade's target moves off the value it was composed with. */
private class ChangeLatch {
var changed = false
}
/**
* A [MyCrossfade] that does not build its [Transition] until there is something to animate.
*
* `updateTransition` allocates a `Transition`, its animation list and its seeking state on *first
* composition*, even though first composition has nothing to cross-fade — target and initial state
* are the same value. In a feed that is pure waste: every card scrolled in builds a transition per
* animated element, and during a scroll essentially none of them ever run, because the underlying
* counts and icons do not change in the second the card is on screen.
*
* So the plain content is rendered until the target actually moves. At that point the transition is
* built seeded at the *original* value via [MutableTransitionState], and immediately re-targeted at
* the new one — so the first real change still animates, exactly as before. Every later change
* animates through the now-live transition normally.
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
internal fun <T> DeferredCrossfade(
targetState: T,
modifier: Modifier,
contentAlignment: Alignment,
animationSpec: FiniteAnimationSpec<Float>,
label: String,
content: @Composable (T) -> Unit,
) {
val initial = remember { targetState }
val latch = remember { ChangeLatch() }
if (targetState != initial) latch.changed = true
if (!latch.changed) {
Box(modifier, contentAlignment) {
content(targetState)
}
} else {
val transitionState = remember { MutableTransitionState(initial) }
transitionState.targetState = targetState
val transition = rememberTransition(transitionState, label)
transition.MyCrossfade(modifier, contentAlignment, animationSpec, content = content)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun <T> MyCrossfade(
@@ -115,3 +115,54 @@ fun ToggleableBox(
content(isActive)
}
}
/**
* Same as [ClickableBox], but lets Compose create the [MutableInteractionSource] and the ripple
* node lazily, on the first touch, instead of eagerly at composition.
*
* `Modifier.clickable` only takes its lazy path when `interactionSource` is null and `indication`
* is an `IndicationNodeFactory` (which `ripple()` is). Passing a remembered interaction source —
* as [ClickableBox] does — forces both to be built up front, for every button, on every card the
* feed scrolls in, even though the overwhelming majority are never touched.
*
* Behaviour is unchanged: same click, same ripple, same `Role.Button` semantics.
*/
@Composable
fun ClickableBoxLazyRipple(
modifier: Modifier = Modifier,
onClick: () -> Unit,
content: @Composable BoxScope.() -> Unit,
) {
Box(
modifier.clickable(
role = Role.Button,
interactionSource = null,
indication = ripple24dp,
onClick = onClick,
),
contentAlignment = Alignment.Center,
content = content,
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ClickableBoxLazyRipple(
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
content: @Composable () -> Unit,
) {
Box(
modifier.combinedClickable(
role = Role.Button,
interactionSource = null,
indication = ripple24dp,
onClick = onClick,
onLongClick = onLongClick,
),
contentAlignment = Alignment.Center,
) {
content()
}
}
@@ -41,10 +41,16 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.asDrawable
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.LocalPlatformContext
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import coil3.request.ImageRequest
import coil3.request.transformations
import coil3.transform.CircleCropTransformation
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
import com.vitorpamplona.amethyst.commons.richtext.bridgeProfilePictureUrl
@@ -148,6 +154,30 @@ fun RobohashFallbackAsyncImage(
}
val resources = LocalContext.current.resources
if (BuildConfig.PROBE_NO_SUBCOMPOSE_AVATAR) {
// Probe: same pixels for a loaded image, and the same fallback painter while loading or
// on error — but no subcomposition. The Animatable start/stop below is not needed here:
// animated URLs are routed to GifProfilePicture by the branch above.
AsyncImage(
model =
if (bridgedModel.startsWith("http://", ignoreCase = true) || bridgedModel.startsWith("https://", ignoreCase = true)) {
ProfilePictureUrl(bridgedModel)
} else {
bridgedModel
},
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
placeholder = fallbackPainter,
error = fallbackPainter,
fallback = fallbackPainter,
)
return
}
SubcomposeAsyncImage(
// The thumbnail-cache fetcher behind ProfilePictureUrl delegates to Coil's http-only
// NetworkFetcher, so a LOCAL model (e.g. a decrypted Concord community icon cached at
@@ -237,9 +267,24 @@ fun GifProfilePicture(
)
}
// Probe: bake the circle into the bitmap once instead of clipping every frame.
val platformContext = LocalPlatformContext.current
val avatarModel: Any? =
if (BuildConfig.PROBE_CIRCLE_CROP && userPicture != null) {
remember(userPicture) {
ImageRequest
.Builder(platformContext)
.data(userPicture)
.transformations(CircleCropTransformation())
.build()
}
} else {
userPicture
}
Box(modifier = modifier) {
SubcomposeAsyncImage(
model = userPicture,
model = avatarModel,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
@@ -21,20 +21,22 @@
package com.vitorpamplona.amethyst.ui.feeds
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LocalFeedReactionPainters
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.rememberFeedReactionPainters
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -51,25 +53,34 @@ fun FeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
if (header != null) {
item {
header()
}
}
// One VectorPainter per reaction icon for the whole list rather than one per card. Provided
// here, around the feed only, because a painter caches its raster by draw size and these icons
// appear at other sizes on other screens. See FeedReactionPainters.
val reactionPainters = rememberFeedReactionPainters()
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) {
CompositionLocalProvider(LocalFeedReactionPainters provides reactionPainters) {
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
if (header != null) {
item {
header()
}
}
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
// No wrapper Row: it held nothing but a single child that already fills the width,
// and NoteCompose puts this modifier straight onto NoteComposeLayout (or onto
// BlankNote while the event loads, which needs the fillMaxWidth to keep its width).
// The wrapper cost one layout node plus one fill constraint pass per feed item.
NoteCompose(
item,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().animateItem(),
routeForLastRead = routeForLastRead,
isBoostedNote = false,
isHiddenFeed = items.showHidden,
@@ -77,11 +88,11 @@ fun FeedLoaded(
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
@@ -0,0 +1,67 @@
/*
* 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.ui.note
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.vector.VectorPainter
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import com.vitorpamplona.amethyst.commons.icons.Like
import com.vitorpamplona.amethyst.commons.icons.Reply
import com.vitorpamplona.amethyst.commons.icons.Reposted
/**
* Vector painters for the three [ImageVector][androidx.compose.ui.graphics.vector.ImageVector]
* icons a feed note's reaction row draws, created once and shared by every card on screen.
*
* `Icon(imageVector = …)` calls `rememberVectorPainter` internally, so each call site builds its
* own [VectorPainter], and a painter rasterises its vector into a cached graphics layer **per
* instance**. A feed therefore re-rasterised the same three glyphs once for every card scrolled in
* — measured at roughly 1.8 ms of draw per card, the single largest draw cost in the note.
*
* Sharing is safe here because `Icon` applies `tint` as a draw-time `ColorFilter` rather than
* baking it into the painter, so one painter serves the tinted and untinted states alike.
*
* **Scoped deliberately to the feed row.** A [VectorPainter] caches its raster by draw size, so the
* same instance drawn at two sizes in one frame would re-rasterise on every draw and end up slower
* than not sharing at all. These icons appear at 18/19/20/28 dp in different screens
* (`UserReactionsRow`, `MultiSetCompose`, the reaction gallery); this local is provided only around
* the feed, where each icon has exactly one size, so those other call sites keep their own painters
* and cannot thrash this cache.
*/
@Immutable
class FeedReactionPainters(
val reply: VectorPainter,
val reposted: VectorPainter,
val like: VectorPainter,
)
/** Null when no feed provided painters — call sites then fall back to their own, as before. */
val LocalFeedReactionPainters = staticCompositionLocalOf<FeedReactionPainters?> { null }
@Composable
fun rememberFeedReactionPainters(): FeedReactionPainters =
FeedReactionPainters(
reply = rememberVectorPainter(Reply),
reposted = rememberVectorPainter(Reposted),
like = rememberVectorPainter(Like),
)
@@ -388,37 +388,39 @@ fun NoteCompose(
onClick: (() -> Unit)? = null,
moreOptions: (@Composable () -> Unit)? = null,
) {
WatchNoteEvent(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav,
modifier,
) {
CheckHiddenFeedWatchBlockAndReport(
note = baseNote,
modifier = modifier,
ignoreAllBlocksAndReports = isHiddenFeed,
showHiddenWarning = isQuotedNote || isBoostedNote,
TracedComposition(NoteTrace.CARD) {
WatchNoteEvent(
baseNote = baseNote,
accountViewModel = accountViewModel,
nav = nav,
) { canPreview ->
AcceptableNote(
baseNote = baseNote,
nav,
modifier,
) {
CheckHiddenFeedWatchBlockAndReport(
note = baseNote,
modifier = modifier,
routeForLastRead = routeForLastRead,
isBoostedNote = isBoostedNote,
isQuotedNote = isQuotedNote,
unPackReply = unPackReply,
makeItShort = makeItShort,
canPreview = canPreview,
isPinned = isPinned,
quotesLeft = quotesLeft,
parentBackgroundColor = parentBackgroundColor,
ignoreAllBlocksAndReports = isHiddenFeed,
showHiddenWarning = isQuotedNote || isBoostedNote,
accountViewModel = accountViewModel,
nav = nav,
onClick = onClick,
moreOptions = moreOptions,
)
) { canPreview ->
AcceptableNote(
baseNote = baseNote,
modifier = modifier,
routeForLastRead = routeForLastRead,
isBoostedNote = isBoostedNote,
isQuotedNote = isQuotedNote,
unPackReply = unPackReply,
makeItShort = makeItShort,
canPreview = canPreview,
isPinned = isPinned,
quotesLeft = quotesLeft,
parentBackgroundColor = parentBackgroundColor,
accountViewModel = accountViewModel,
nav = nav,
onClick = onClick,
moreOptions = moreOptions,
)
}
}
}
}
@@ -749,44 +751,52 @@ fun InnerNoteWithReactions(
showContentSpacer = isNotRepost,
authorPicture = {
if (notBoostedNorQuote) {
Box(modifier = Size55Modifier, contentAlignment = Alignment.BottomEnd) {
RenderAuthorImages(baseNote, nav, accountViewModel)
TracedComposition(NoteTrace.AUTHOR_IMAGES) {
Box(modifier = Size55Modifier.tracedDraw(NoteTrace.DRAW_AUTHOR), contentAlignment = Alignment.BottomEnd) {
RenderAuthorImages(baseNote, nav, accountViewModel)
}
}
}
},
firstRow = {
FirstUserInfoRow(
baseNote = baseNote,
showAuthorPicture = isQuotedNote,
isPinned = isPinned,
editState = editState,
accountViewModel = accountViewModel,
nav = nav,
moreOptions = moreOptions,
)
},
secondRow = {
if (showSecondRow) {
SecondUserInfoRow(
baseNote,
accountViewModel,
nav,
TracedComposition(NoteTrace.FIRST_ROW) {
FirstUserInfoRow(
baseNote = baseNote,
showAuthorPicture = isQuotedNote,
isPinned = isPinned,
editState = editState,
accountViewModel = accountViewModel,
nav = nav,
moreOptions = moreOptions,
)
}
},
secondRow = {
if (showSecondRow) {
TracedComposition(NoteTrace.SECOND_ROW) {
SecondUserInfoRow(
baseNote,
accountViewModel,
nav,
)
}
}
},
noteContent = {
RenderNoteRow(
baseNote = baseNote,
backgroundColor = backgroundColor,
makeItShort = makeItShort,
canPreview = canPreview,
editState = editState,
quotesLeft = quotesLeft,
unPackReply = unPackReply,
accountViewModel = accountViewModel,
nav = nav,
isBoostedNote = isBoostedNote,
)
TracedComposition(NoteTrace.CONTENT) {
RenderNoteRow(
baseNote = baseNote,
backgroundColor = backgroundColor,
makeItShort = makeItShort,
canPreview = canPreview,
editState = editState,
quotesLeft = quotesLeft,
unPackReply = unPackReply,
accountViewModel = accountViewModel,
nav = nav,
isBoostedNote = isBoostedNote,
)
}
if (!makeItShort) {
val noteEvent = baseNote.event
@@ -804,14 +814,16 @@ fun InnerNoteWithReactions(
if (makeItShort) {
Spacer(modifier = DoubleVertSpacer)
} else {
ReactionsRow(
baseNote = baseNote,
showReactionDetail = notBoostedNorQuote,
addPadding = !isBoostedNote,
editState = editState,
accountViewModel = accountViewModel,
nav = nav,
)
TracedComposition(NoteTrace.REACTIONS) {
ReactionsRow(
baseNote = baseNote,
showReactionDetail = notBoostedNorQuote,
addPadding = !isBoostedNote,
editState = editState,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
} else if (baseNote.event is DraftWrapEvent) {
Spacer(modifier = DoubleVertSpacer)
@@ -1867,7 +1879,7 @@ fun FirstUserInfoRow(
Row(
verticalAlignment = CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Size5dp),
modifier = UserNameRowHeight,
modifier = UserNameRowHeight.tracedDraw(NoteTrace.DRAW_FIRST_ROW),
) {
val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
val isDraft = baseNote.isDraft()
@@ -0,0 +1,141 @@
/*
* 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.ui.note
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.tracing.Trace
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.ReactionRowAction
/**
* Composition-time trace markers for the feed's note card.
*
* Compose runs a composable's body synchronously on the composing thread, so a
* `beginSection`/`endSection` pair wrapped around a `content()` call measures the
* **composition** cost of that subtree — deliberately excluding the later measure,
* layout and draw phases, which is what makes this useful for separating "building
* the tree" from "drawing it".
*
* Gated on [BuildConfig.TRACE_NOTE_RENDER], `true` only in the `benchmark` build type, so
* the sections never execute in `debug` or `release`.
*
* They are **not stripped**, though — verified by grepping the R8'd release DEX, which
* still contains the marker strings, this file's classes and a reference to
* `androidx.tracing.Trace`. R8 cannot prove the branch dead, most likely because this is
* a `@Composable inline` function the Compose plugin rewrites before R8 sees it. Runtime
* cost in a shipped build is nil; APK footprint is not zero. Before this lands on `main`,
* move the tracer behind a source-set split (no-op for debug/release, real one only in
* `benchmark`).
*
* Section names are read back by `TraceSectionMetric` in
* `:macrobenchmark`'s `FeedScrollBenchmark`, so they must stay in sync with the names
* listed there.
*/
@Composable
inline fun TracedComposition(
name: String,
content: @Composable () -> Unit,
) {
if (!BuildConfig.TRACE_NOTE_RENDER) {
content()
return
}
// No try/finally: the Compose compiler rejects a try/catch around a composable call.
// An exception thrown during composition aborts the frame regardless, so an unbalanced
// section in that case is not a concern.
Trace.beginSection(name)
content()
Trace.endSection()
}
/**
* Draw-phase counterpart of [TracedComposition].
*
* Composition markers cannot see the draw phase at all, and on a slow device draw is roughly three
* times the whole layout phase. `drawWithContent` wraps the subtree's paint work, so the section
* measures exactly the time spent rasterising that part of the card.
*
* Deliberately attached to modifiers that already exist on real composables rather than to new
* wrapper `Box`es: adding a node would change the layout being measured. Benchmark builds only —
* elsewhere the constant folds and this returns the receiver untouched.
*/
fun Modifier.tracedDraw(name: String): Modifier =
if (!BuildConfig.TRACE_NOTE_RENDER) {
this
} else {
drawWithContent {
Trace.beginSection(name)
drawContent()
Trace.endSection()
}
}
/** Section names. Kept as constants so R8 cannot rewrite them apart from the metric list. */
object NoteTrace {
const val CARD = "Amethyst:NoteCard"
const val WATCH_EVENT = "Amethyst:WatchNoteEvent"
const val HIDDEN_CHECK = "Amethyst:HiddenCheck"
const val BG_COLOR = "Amethyst:BackgroundColor"
const val AUTHOR_IMAGES = "Amethyst:AuthorImages"
const val FIRST_ROW = "Amethyst:FirstUserInfoRow"
const val SECOND_ROW = "Amethyst:SecondUserInfoRow"
const val CONTENT = "Amethyst:NoteContent"
const val REACTIONS = "Amethyst:ReactionsRow"
const val DISPATCH = "Amethyst:KindDispatch"
// Draw-phase sections (see Modifier.tracedDraw).
const val DRAW_REACTIONS = "Amethyst:DrawReactions"
const val DRAW_AUTHOR = "Amethyst:DrawAuthor"
const val DRAW_FIRST_ROW = "Amethyst:DrawFirstRow"
const val DRAW_RICHTEXT = "Amethyst:DrawRichText"
/** Fires only when a feed icon actually used the shared painter — proves the fix is live. */
const val SHARED_PAINTER = "Amethyst:SharedPainter"
// Drill-down inside ReactionsRow, the single most expensive slot of the card.
const val RX_INDICATORS = "Amethyst:RxIndicators"
const val RX_ZAPRAISER = "Amethyst:RxZapraiser"
const val RX_REPLY = "Amethyst:RxReply"
const val RX_BOOST = "Amethyst:RxBoost"
const val RX_LIKE = "Amethyst:RxLike"
const val RX_ZAP = "Amethyst:RxZap"
const val RX_SHARE = "Amethyst:RxShare"
const val RX_PAY = "Amethyst:RxPay"
// Drill-down inside NoteContent for a plain text note (the common feed case).
const val TXT_REPLY = "Amethyst:TxtReplyPreview"
const val TXT_RICHTEXT = "Amethyst:TxtRichText"
const val TXT_HASHTAGS = "Amethyst:TxtHashtags"
/** Section name for one reaction-row button. */
fun forAction(action: ReactionRowAction) =
when (action) {
ReactionRowAction.Reply -> RX_REPLY
ReactionRowAction.Boost -> RX_BOOST
ReactionRowAction.Like -> RX_LIKE
ReactionRowAction.Zap -> RX_ZAP
ReactionRowAction.Share -> RX_SHARE
ReactionRowAction.Pay -> RX_PAY
}
}
@@ -31,6 +31,7 @@ import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
@@ -48,6 +49,7 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
@@ -106,14 +108,17 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
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.commons.icons.symbols.amethystIconsFontFamily
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
@@ -126,6 +131,7 @@ import com.vitorpamplona.amethyst.model.zap.CashuRailStatus
import com.vitorpamplona.amethyst.model.zap.RailCapability
import com.vitorpamplona.amethyst.model.zap.RailCapabilityResolver
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.collectAsStateProbed
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactionCount
@@ -143,6 +149,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.ClickableBoxLazyRipple
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -227,7 +234,9 @@ fun ReactionsRow(
InnerReactionRow(baseNote, showReactionDetail, addPadding, wantsToSeeReactions, editState, accountViewModel, nav)
LoadAndDisplayZapraiser(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel)
TracedComposition(NoteTrace.RX_ZAPRAISER) {
LoadAndDisplayZapraiser(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel)
}
if (showReactionDetail && wantsToSeeReactions.value) {
ReactionDetailGallery(baseNote, nav, accountViewModel)
@@ -246,15 +255,17 @@ private fun InnerReactionRow(
nav: INav,
) {
val voiceRecordingState = remember(baseNote.idHex) { mutableStateOf(false) }
val reactionRowItems by accountViewModel.reactionRowItemsFlow().collectAsStateWithLifecycle()
val reactionRowItems by accountViewModel.reactionRowItemsFlow().collectAsStateProbed()
GenericInnerReactionRow(
showReactionDetail = showReactionDetail,
addPadding = addPadding,
weightTwo = if (voiceRecordingState.value) 2f else 1f,
one = {
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote, accountViewModel) {
RenderShowIndividualReactionsButton(wantsToSeeReactions, accountViewModel)
TracedComposition(NoteTrace.RX_INDICATORS) {
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote, accountViewModel) {
RenderShowIndividualReactionsButton(wantsToSeeReactions, accountViewModel)
}
}
},
reactions = reactionRowItems,
@@ -270,71 +281,73 @@ private fun InnerReactionRow(
// shared: the reference points at a membership-gated group event that non-members can't fetch,
// and a DM shouldn't be rebroadcast at all. Reply/like/zap stay (reply routes into the group).
val isRelayGroupMessage = baseNote.inGatherers?.any { it is RelayGroupChannel } == true
when (item.action) {
ReactionRowAction.Reply -> {
ReplyReactionWithDialog(
baseNote,
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav,
showCounter = item.showCounter,
voiceRecordingState = voiceRecordingState,
)
}
ReactionRowAction.Boost -> {
val isDM = baseNote.event is ChatroomKeyable
if (!isDM && !isPrivateRumor && !isRelayGroupMessage) {
BoostWithDialog(
TracedComposition(NoteTrace.forAction(item.action)) {
when (item.action) {
ReactionRowAction.Reply -> {
ReplyReactionWithDialog(
baseNote,
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav,
showCounter = item.showCounter,
voiceRecordingState = voiceRecordingState,
)
}
ReactionRowAction.Boost -> {
val isDM = baseNote.event is ChatroomKeyable
if (!isDM && !isPrivateRumor && !isRelayGroupMessage) {
BoostWithDialog(
baseNote,
editState,
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav,
showCounter = item.showCounter,
)
}
}
ReactionRowAction.Like -> {
LikeReaction(
baseNote,
editState,
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav,
showCounter = item.showCounter,
)
}
}
ReactionRowAction.Like -> {
LikeReaction(
baseNote,
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav,
showCounter = item.showCounter,
)
}
ReactionRowAction.Zap -> {
// Zaps stay enabled on private rumors: AccountViewModel.zap
// forces the PRIVATE zap type, and the public nutzap/onchain
// rails are suppressed for them.
ZapReaction(
baseNote,
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav = nav,
showCounter = item.showCounter,
)
}
ReactionRowAction.Share -> {
if (!isPrivateRumor && !isRelayGroupMessage) {
ShareReaction(
note = baseNote,
ReactionRowAction.Zap -> {
// Zaps stay enabled on private rumors: AccountViewModel.zap
// forces the PRIVATE zap type, and the public nutzap/onchain
// rails are suppressed for them.
ZapReaction(
baseNote,
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav = nav,
grayTint = MaterialTheme.colorScheme.placeholderText,
showCounter = item.showCounter,
)
}
}
ReactionRowAction.Pay -> {
PayReaction(
baseNote = baseNote,
grayTint = MaterialTheme.colorScheme.placeholderText,
accountViewModel = accountViewModel,
)
ReactionRowAction.Share -> {
if (!isPrivateRumor && !isRelayGroupMessage) {
ShareReaction(
note = baseNote,
nav = nav,
grayTint = MaterialTheme.colorScheme.placeholderText,
)
}
}
ReactionRowAction.Pay -> {
PayReaction(
baseNote = baseNote,
grayTint = MaterialTheme.colorScheme.placeholderText,
accountViewModel = accountViewModel,
)
}
}
}
},
@@ -350,11 +363,11 @@ fun ShareReaction(
) {
var showShareSheet by remember { mutableStateOf(false) }
ClickableBox(
RxClickableBox(
modifier = barChartModifier,
onClick = { showShareSheet = true },
) {
ShareIcon(barChartModifier, grayTint)
RxIcon(barChartModifier, RxIconKind.GLYPH) { ShareIcon(barChartModifier, grayTint) }
}
if (showShareSheet) {
@@ -379,7 +392,7 @@ fun PayReaction(
LoadAddressableNote(address, accountViewModel) { note ->
var expanded by remember { mutableStateOf(false) }
ClickableBox(
RxClickableBox(
modifier = iconSizeModifier,
onClick = { expanded = true },
) {
@@ -416,7 +429,7 @@ private fun GenericInnerReactionRow(
Row(
verticalAlignment = CenterVertically,
horizontalArrangement = RowColSpacing,
modifier = if (addPadding) ReactionRowHeightWithPadding else ReactionRowHeight,
modifier = (if (addPadding) ReactionRowHeightWithPadding else ReactionRowHeight).tracedDraw(NoteTrace.DRAW_REACTIONS),
) {
if (showReactionDetail) {
Row(
@@ -544,19 +557,19 @@ private fun RenderShowIndividualReactionsButton(
wantsToSeeReactions: MutableState<Boolean>,
accountViewModel: AccountViewModel,
) {
ClickableBox(
RxClickableBox(
onClick = { wantsToSeeReactions.value = !wantsToSeeReactions.value },
modifier = Size20Modifier,
) {
CrossfadeIfEnabled(
RxCrossfade(
targetState = wantsToSeeReactions.value,
label = "RenderShowIndividualReactionsButton",
accountViewModel = accountViewModel,
) {
if (it) {
ExpandLessIcon(modifier = Size22Modifier, R.string.close_all_reactions_to_this_post)
RxIcon(Size22Modifier, RxIconKind.GLYPH) { ExpandLessIcon(modifier = Size22Modifier, R.string.close_all_reactions_to_this_post) }
} else {
ExpandMoreIcon(modifier = Size22Modifier, R.string.open_all_reactions_to_this_post)
RxIcon(Size22Modifier, RxIconKind.GLYPH) { ExpandMoreIcon(modifier = Size22Modifier, R.string.open_all_reactions_to_this_post) }
}
}
}
@@ -791,7 +804,7 @@ fun ReplyViaVoiceReaction(
onClick = onStop,
)
} else {
VoiceReplyIcon(iconSizeModifier, grayTint)
RxIcon(iconSizeModifier, RxIconKind.GLYPH) { VoiceReplyIcon(iconSizeModifier, grayTint) }
}
}
@@ -809,7 +822,7 @@ fun ReplyReaction(
iconSizeModifier: Modifier = Size19Modifier,
onPress: () -> Unit,
) {
ClickableBox(
RxClickableBox(
modifier = iconSizeModifier,
onClick = {
if (baseNote.isDraft()) {
@@ -829,7 +842,7 @@ fun ReplyReaction(
}
},
) {
CommentIcon(iconSizeModifier, grayTint)
RxIcon(iconSizeModifier, RxIconKind.VECTOR) { FeedCommentIcon(iconSizeModifier, grayTint) }
}
if (showCounter) {
@@ -848,14 +861,294 @@ fun ReplyCounter(
SlidingAnimationCount(repliesState, textColor, accountViewModel)
}
/** Latches the first time an animated counter's value moves off the one it was composed with. */
private class CountChangeLatch {
var changed = false
}
/**
* An [AnimatedContent] that does not build its transition until the value actually changes.
*
* Same reasoning as `DeferredCrossfade`: `AnimatedContent` builds a `Transition` plus its content
* map and size animation on first composition, but first composition has nothing to animate. A
* reaction counter only slides when the count moves, which practically never happens in the second
* a card spends on screen during a scroll — so the whole apparatus is built and thrown away, once
* per counter per card.
*
* Rendering the bare content until the first change, then seeding a [MutableTransitionState] at the
* original value, keeps that first change animated exactly as before.
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
internal fun <T> DeferredAnimatedContent(
targetState: T,
label: String,
content: @Composable (T) -> Unit,
) {
val initial = remember { targetState }
val latch = remember { CountChangeLatch() }
if (targetState != initial) latch.changed = true
if (!latch.changed) {
content(targetState)
} else {
val transitionState = remember { MutableTransitionState(initial) }
transitionState.targetState = targetState
val transition = rememberTransition(transitionState, label)
transition.AnimatedContent(
transitionSpec = { transitionSpec() },
) { value ->
content(value)
}
}
}
/**
* Reaction-row icons that prefer the feed's shared [VectorPainter] when one is available.
*
* Falls back to the ordinary `Icon(imageVector = …)` path whenever no feed provided painters — a
* preview, a dialog, or any screen outside the feed — so behaviour and appearance are unchanged
* everywhere. See [FeedReactionPainters] for why the sharing is scoped rather than global.
*/
@Composable
private fun FeedCommentIcon(
iconSizeModifier: Modifier,
tint: Color,
) {
if (BuildConfig.PROBE_RX_CUSTOM_FONT) {
Material3Icon(
painter = rememberMaterialSymbolPainter(AmethystIcons.Reply, tint, amethystIconsFontFamily()),
contentDescription = null,
modifier = iconSizeModifier,
tint = Color.Unspecified,
)
return
}
if (BuildConfig.PROBE_RX_GLYPH_SWAP) {
Icon(
symbol = MaterialSymbols.Chat,
contentDescription = stringRes(id = R.string.reply_description),
modifier = iconSizeModifier,
tint = tint,
)
return
}
val shared = LocalFeedReactionPainters.current
if (BuildConfig.FIX_SHARED_ICONS && shared != null) {
TracedComposition(NoteTrace.SHARED_PAINTER) {
Material3Icon(
painter = shared.reply,
contentDescription = stringRes(id = R.string.reply_description),
modifier = iconSizeModifier,
tint = tint,
)
}
} else {
CommentIcon(iconSizeModifier, tint)
}
}
@Composable
private fun FeedRepostedIcon(
iconSizeModifier: Modifier,
tint: Color,
) {
if (BuildConfig.PROBE_RX_CUSTOM_FONT) {
Material3Icon(
painter = rememberMaterialSymbolPainter(AmethystIcons.Reposted, tint, amethystIconsFontFamily()),
contentDescription = null,
modifier = iconSizeModifier,
tint = Color.Unspecified,
)
return
}
if (BuildConfig.PROBE_RX_GLYPH_SWAP) {
Icon(
symbol = MaterialSymbols.Sync,
contentDescription = stringRes(id = R.string.boost_or_quote_description),
modifier = iconSizeModifier,
tint = tint,
)
return
}
val shared = LocalFeedReactionPainters.current
if (BuildConfig.FIX_SHARED_ICONS && shared != null) {
TracedComposition(NoteTrace.SHARED_PAINTER) {
Material3Icon(
painter = shared.reposted,
contentDescription = stringRes(id = R.string.boost_or_quote_description),
modifier = iconSizeModifier,
tint = tint,
)
}
} else {
RepostedIcon(iconSizeModifier, tint)
}
}
@Composable
private fun FeedLikeIcon(
iconSizeModifier: Modifier,
tint: Color,
) {
if (BuildConfig.PROBE_RX_CUSTOM_FONT) {
Material3Icon(
painter = rememberMaterialSymbolPainter(AmethystIcons.Like, tint, amethystIconsFontFamily()),
contentDescription = null,
modifier = iconSizeModifier,
tint = Color.Unspecified,
)
return
}
if (BuildConfig.PROBE_RX_GLYPH_SWAP) {
Icon(
symbol = MaterialSymbols.Favorite,
contentDescription = stringRes(id = R.string.like_description),
modifier = iconSizeModifier,
tint = tint,
)
return
}
val shared = LocalFeedReactionPainters.current
if (BuildConfig.FIX_SHARED_ICONS && shared != null) {
TracedComposition(NoteTrace.SHARED_PAINTER) {
Material3Icon(
painter = shared.like,
contentDescription = stringRes(id = R.string.like_description),
modifier = iconSizeModifier,
tint = tint,
)
}
} else {
LikeIcon(iconSizeModifier, tint)
}
}
/** Which rasterisation path an icon takes, so the two can be ablated independently. */
private enum class RxIconKind {
/** `Icon(imageVector = …)` — a VectorPainter rasterising paths into a cached layer. */
VECTOR,
/** `Icon(symbol = …)` — a glyph from the bundled MaterialSymbols subset font. */
GLYPH,
}
/**
* **Measurement probe helper — not a feature.**
*
* Draws nothing where a reaction icon would go, keeping a node of the same size so the row lays out
* unchanged. [kind] lets the vector-path icons and the MaterialSymbols glyph icons be ablated
* separately, since a combined ablation says only how much the icons cost in total, not which
* rasterisation path is responsible.
*
* Probe builds show a row of blank gaps; it exists to be measured, not shipped.
*/
@Composable
private inline fun RxIcon(
modifier: Modifier,
kind: RxIconKind,
icon: @Composable () -> Unit,
) {
val hide =
BuildConfig.PROBE_NO_RX_ICONS ||
when (kind) {
RxIconKind.VECTOR -> BuildConfig.PROBE_NO_RX_VECTOR_ICONS
RxIconKind.GLYPH -> BuildConfig.PROBE_NO_RX_GLYPH_ICONS
}
if (hide) {
Spacer(modifier)
} else {
icon()
}
}
/**
* **Measurement probe helper — not a feature.**
*
* When `PROBE_NO_RX_CLICKABLE` is on, renders the reaction button's content in a bare [Box] with
* no `clickable` modifier at all — no `MutableInteractionSource` allocated, no ripple
* [androidx.compose.foundation.IndicationNodeFactory] attached, no `Role.Button` semantics.
*
* This is an upper bound, not a candidate fix: a probe build's reaction buttons do not respond to
* taps. It exists to answer whether the ~6 eagerly-built clickable/ripple nodes per row are worth
* optimising before any behaviour-preserving version (a lazy interaction source) is attempted.
*/
@Composable
private fun RxClickableBox(
modifier: Modifier = Modifier,
onClick: () -> Unit,
content: @Composable BoxScope.() -> Unit,
) {
if (BuildConfig.PROBE_NO_RX_CLICKABLE) {
Box(modifier, contentAlignment = Alignment.Center, content = content)
} else if (BuildConfig.PROBE_LAZY_RX_RIPPLE) {
ClickableBoxLazyRipple(modifier = modifier, onClick = onClick, content = content)
} else {
ClickableBox(modifier = modifier, onClick = onClick, content = content)
}
}
@Composable
private fun RxClickableBox(
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
content: @Composable () -> Unit,
) {
if (BuildConfig.PROBE_NO_RX_CLICKABLE) {
Box(modifier, contentAlignment = Alignment.Center) { content() }
} else if (BuildConfig.PROBE_LAZY_RX_RIPPLE) {
ClickableBoxLazyRipple(modifier = modifier, onClick = onClick, onLongClick = onLongClick, content = content)
} else {
ClickableBox(modifier = modifier, onClick = onClick, onLongClick = onLongClick, content = content)
}
}
/**
* **Measurement probe helper — not a feature.**
*
* Same contract as [CrossfadeIfEnabled], but when `PROBE_NO_RX_ANIMATIONS` is on it takes the
* non-animated branch that `CrossfadeIfEnabled` already has for performance mode: the identical
* `Box` + content, with no `updateTransition` built. Scoped to the reaction row on purpose, so
* the rest of the card keeps animating and acts as a control.
*/
@Composable
private fun <T> RxCrossfade(
targetState: T,
modifier: Modifier = Modifier,
contentAlignment: Alignment = Alignment.TopStart,
label: String = "Crossfade",
accountViewModel: AccountViewModel,
content: @Composable (T) -> Unit,
) {
if (BuildConfig.PROBE_NO_RX_ANIMATIONS) {
Box(modifier, contentAlignment) {
content(targetState)
}
} else {
CrossfadeIfEnabled(
targetState = targetState,
modifier = modifier,
contentAlignment = contentAlignment,
label = label,
accountViewModel = accountViewModel,
content = content,
)
}
}
@Composable
private fun SlidingAnimationCount(
baseCount: Int,
textColor: Color,
accountViewModel: AccountViewModel,
) {
if (accountViewModel.settings.isPerformanceMode()) {
if (BuildConfig.PROBE_NO_RX_ANIMATIONS || accountViewModel.settings.isPerformanceMode()) {
TextCount(baseCount, textColor)
} else if (BuildConfig.FIX_LAZY_ANIM) {
DeferredAnimatedContent(baseCount, "SlidingAnimationCount") { count ->
TextCount(count, textColor)
}
} else {
AnimatedContent(
targetState = baseCount,
@@ -889,6 +1182,13 @@ fun TextCount(
count: Int,
textColor: Color,
) {
if (BuildConfig.PROBE_NO_RX_COUNTERS) {
// Probe: keep a layout node of roughly the same width so the row still lays out the same
// way, but do no text shaping. The delta against a normal build is the counter's
// Paragraph construction + glyph shaping.
Spacer(ProbeCounterWidth)
return
}
Text(
text = showCount(count),
fontSize = Font14SP,
@@ -897,13 +1197,20 @@ fun TextCount(
)
}
/** Stand-in width for a 2-3 digit counter, probe builds only. */
private val ProbeCounterWidth = Modifier.width(20.dp)
@Composable
fun SlidingAnimationAmount(
amount: String,
textColor: Color,
accountViewModel: AccountViewModel,
) {
if (accountViewModel.settings.isPerformanceMode()) {
if (BuildConfig.PROBE_NO_RX_COUNTERS) {
Spacer(ProbeCounterWidth)
return
}
if (BuildConfig.PROBE_NO_RX_ANIMATIONS || accountViewModel.settings.isPerformanceMode()) {
Text(
text = amount,
fontSize = Font14SP,
@@ -911,17 +1218,28 @@ fun SlidingAnimationAmount(
maxLines = 1,
)
} else {
AnimatedContent(
targetState = amount,
transitionSpec = AnimatedContentTransitionScope<String>::transitionSpec,
label = "SlidingAnimationAmount",
) { count ->
Text(
text = count,
fontSize = Font14SP,
color = textColor,
maxLines = 1,
)
if (BuildConfig.FIX_LAZY_ANIM) {
DeferredAnimatedContent(amount, "SlidingAnimationAmount") { count ->
Text(
text = count,
fontSize = Font14SP,
color = textColor,
maxLines = 1,
)
}
} else {
AnimatedContent(
targetState = amount,
transitionSpec = AnimatedContentTransitionScope<String>::transitionSpec,
label = "SlidingAnimationAmount",
) { count ->
Text(
text = count,
fontSize = Font14SP,
color = textColor,
maxLines = 1,
)
}
}
}
}
@@ -939,14 +1257,14 @@ fun BoostReaction(
) {
var wantsToBoost by remember { mutableStateOf(false) }
ClickableBox(
RxClickableBox(
modifier = iconSizeModifier,
onClick = {
accountViewModel.tryBoost(baseNote) { wantsToBoost = true }
},
) {
ObserveBoostIcon(baseNote, accountViewModel) { hasBoosted ->
RepostedIcon(iconSizeModifier, if (hasBoosted) Color.Unspecified else grayTint)
RxIcon(iconSizeModifier, RxIconKind.VECTOR) { FeedRepostedIcon(iconSizeModifier, if (hasBoosted) Color.Unspecified else grayTint) }
}
if (wantsToBoost) {
@@ -1010,7 +1328,7 @@ fun LikeReaction(
) {
var wantsToReact by remember { mutableStateOf(false) }
ClickableBox(
RxClickableBox(
onClick = {
likeClick(
accountViewModel,
@@ -1022,11 +1340,11 @@ fun LikeReaction(
onLongClick = { nav.nav(Route.UpdateReactionType) },
) {
ObserveLikeIcon(baseNote, accountViewModel) { reactionType ->
CrossfadeIfEnabled(targetState = reactionType, contentAlignment = Center, label = "LikeIcon", accountViewModel = accountViewModel) {
RxCrossfade(targetState = reactionType, contentAlignment = Center, label = "LikeIcon", accountViewModel = accountViewModel) {
if (reactionType != null) {
RenderReactionType(reactionType, heartSizeModifier, iconFontSize)
} else {
LikeIcon(heartSizeModifier, grayTint)
RxIcon(heartSizeModifier, RxIconKind.VECTOR) { FeedLikeIcon(heartSizeModifier, grayTint) }
}
}
}
@@ -1060,10 +1378,16 @@ fun ObserveLikeIcon(
@Suppress("ProduceStateDoesNotAssignValue")
val reactionType by
produceState(initialValue = null as String?, key1 = reactionsState) {
val newReactionType = accountViewModel.loadReactionTo(reactionsState?.note)
if (value != newReactionType) {
value = newReactionType
if (BuildConfig.PROBE_NO_RX_PRODUCE_STATE) {
// Probe: same initial value produceState would show on first composition, but the
// coroutine that resolves the user's own reaction is never launched.
remember { mutableStateOf(null as String?) }
} else {
produceState(initialValue = null as String?, key1 = reactionsState) {
val newReactionType = accountViewModel.loadReactionTo(reactionsState?.note)
if (value != newReactionType) {
value = newReactionType
}
}
}
@@ -1355,9 +1679,9 @@ fun ZapReaction(
accountViewModel,
zapStartingTime,
) { zapIconState ->
CrossfadeIfEnabled(targetState = zapIconState, label = "ZapIcon", accountViewModel = accountViewModel) {
RxCrossfade(targetState = zapIconState, label = "ZapIcon", accountViewModel = accountViewModel) {
if (it.wasZappedByLoggedInUser) {
ZappedIcon(iconSizeModifier)
RxIcon(iconSizeModifier, RxIconKind.GLYPH) { ZappedIcon(iconSizeModifier) }
} else {
TwoStageZapProgressIcon(animatedProgress, animationModifier, grayTint)
}
@@ -1368,11 +1692,11 @@ fun ZapReaction(
baseNote,
accountViewModel,
) { zapIconState ->
CrossfadeIfEnabled(targetState = zapIconState, label = "ZapIcon", accountViewModel = accountViewModel) {
RxCrossfade(targetState = zapIconState, label = "ZapIcon", accountViewModel = accountViewModel) {
if (it.wasZappedByLoggedInUser) {
ZappedIcon(iconSizeModifier)
RxIcon(iconSizeModifier, RxIconKind.GLYPH) { ZappedIcon(iconSizeModifier) }
} else if (it.hasPendingPaymentRequest) {
ZapIcon(iconSizeModifier, MaterialTheme.colorScheme.primary)
RxIcon(iconSizeModifier, RxIconKind.GLYPH) { ZapIcon(iconSizeModifier, MaterialTheme.colorScheme.primary) }
} else {
OutlinedZapIcon(iconSizeModifier, grayTint)
}
@@ -1590,11 +1914,17 @@ fun ObserveZapAmountText(
@Suppress("ProduceStateDoesNotAssignValue")
val zapAmountTxt by
produceState(initialValue = showAmount(baseNote.zapsAmount), key1 = zapsState) {
zapsState?.note?.let {
val newZapAmount = accountViewModel.calculateZapAmount(it)
if (value != newZapAmount) {
value = newZapAmount
if (BuildConfig.PROBE_NO_RX_PRODUCE_STATE) {
// Probe: produceState's own initial value, so the rendered amount is identical on
// first composition; only the recalculation coroutine is skipped.
remember { mutableStateOf(showAmount(baseNote.zapsAmount)) }
} else {
produceState(initialValue = showAmount(baseNote.zapsAmount), key1 = zapsState) {
zapsState?.note?.let {
val newZapAmount = accountViewModel.calculateZapAmount(it)
if (value != newZapAmount) {
value = newZapAmount
}
}
}
}
@@ -1851,7 +2181,7 @@ fun ReactionChoicePopup(
) {
val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() }
val reactions by accountViewModel.reactionChoicesFlow().collectAsStateWithLifecycle()
val reactions by accountViewModel.reactionChoicesFlow().collectAsStateProbed()
val toRemove = remember { baseNote.allReactionsByAuthor(accountViewModel.userProfile()).toImmutableSet() }
Popup(
@@ -1909,7 +2239,7 @@ fun ReactionChoicePopupContent(
)
}
ClickableBox(modifier = reactionBox, onClick = onChangeAmount) {
RxClickableBox(modifier = reactionBox, onClick = onChangeAmount) {
ChangeReactionIcon(modifier = Size28Modifier, MaterialTheme.colorScheme.placeholderText)
}
}
@@ -1960,7 +2290,7 @@ private fun ActionableReactionButton(
onChangeAmount: () -> Unit,
toRemove: ImmutableSet<String>,
) {
ClickableBox(
RxClickableBox(
modifier = if (reactionType in toRemove) MaterialTheme.colorScheme.selectedReactionBoxModifier else reactionBox,
onClick = onClick,
onLongClick = onChangeAmount,
@@ -2034,7 +2364,7 @@ fun ZapAmountChoicePopup(
) {
val zapAmountChoices by
accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
.collectAsStateWithLifecycle()
.collectAsStateProbed()
ZapAmountChoicePopup(
baseNote = baseNote,
@@ -2073,15 +2403,15 @@ fun observeZapRailCapability(
// open), and each value is a remember() key so railCapability recomputes when
// it arrives. RailCapabilityResolver.peek re-reads everything itself; these
// just say *when* to re-run it.
val cashuMints by cashuState.mints.collectAsStateWithLifecycle()
val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle()
val cashuMints by cashuState.mints.collectAsStateProbed()
val cashuEntries by cashuState.tokenEntries.collectAsStateProbed()
val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value }
val nutzapInfo = author?.let { observeNoteEvent<NutzapInfoEvent>(it.nutzapInfoNote, accountViewModel).value }
// Honors the user's "show on-chain wallet" preference: off hides the on-chain
// rail from the zap chips too, matching the wallet screen, profile chips, and
// Send Payment screen.
val showOnchainWallet by accountViewModel.settings.uiSettingsFlow.showOnchainWallet
.collectAsStateWithLifecycle()
.collectAsStateProbed()
return remember(baseNote, onchainSupported, showOnchainWallet, cashuMints, cashuEntries, recipientInfo, nutzapInfo) {
val rc = RailCapabilityResolver.peek(baseNote, cashuState)
@@ -2285,7 +2615,7 @@ fun ZapAmountChoiceGrid(
onChangeAmount = onChangeAmount,
)
}
ClickableBox(
RxClickableBox(
modifier =
Modifier
.padding(horizontal = 4.dp, vertical = 6.dp)
@@ -44,6 +44,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -103,7 +104,11 @@ fun DisplayBlankAuthor(
) {
val nullModifier =
remember {
modifier.size(size).clip(shape = CircleShape)
if (BuildConfig.PROBE_NO_AVATAR_CLIP || BuildConfig.PROBE_CIRCLE_CROP) {
modifier.size(size)
} else {
modifier.size(size).clip(shape = CircleShape)
}
}
RobohashAsyncImage(
@@ -621,7 +626,11 @@ fun InnerUserPicture(
) {
val myImageModifier =
remember {
modifier.size(size).clip(shape = CircleShape)
if (BuildConfig.PROBE_NO_AVATAR_CLIP || BuildConfig.PROBE_CIRCLE_CROP) {
modifier.size(size)
} else {
modifier.size(size).clip(shape = CircleShape)
}
}
RobohashFallbackAsyncImage(
@@ -45,9 +45,12 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.NoteTrace
import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition
import com.vitorpamplona.amethyst.ui.note.TracedComposition
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayCommentScope
import com.vitorpamplona.amethyst.ui.note.tracedDraw
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.PreloadThreadForReply
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
@@ -104,48 +107,50 @@ fun RenderTextEvent(
val noteEvent = note.event ?: return
if (unPackReply != ReplyRenderType.NONE) {
// Eagerly pull the rest of this reply's thread while it's on screen, so opening
// the conversation finds it already loaded. No-op when the note is a root itself.
PreloadThreadForReply(note, accountViewModel)
TracedComposition(NoteTrace.TXT_REPLY) {
// Eagerly pull the rest of this reply's thread while it's on screen, so opening
// the conversation finds it already loaded. No-op when the note is a root itself.
PreloadThreadForReply(note, accountViewModel)
val canShowReply by
remember(note) {
derivedStateOf {
noteEvent is BaseThreadedEvent && !makeItShort && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
}
}
val parentNote = remember(note) { replyingDirectlyTo(note, LocalCache) }
if (parentNote != null && canShowReply) {
when (unPackReply) {
ReplyRenderType.FULL -> {
ReplyNoteComposition(parentNote, backgroundColor, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
}
ReplyRenderType.LINE -> {
// Zap receipts are signed by the recipient's lightning provider;
// label the reply with the zap sender instead of the service key.
val zapSender =
if (parentNote.event is LnZapEvent) {
observeZapSender(parentNote, accountViewModel).value
} else {
null
}
val parentAuthor = zapSender ?: parentNote.author
if (parentAuthor != null) {
ReplyToLabel(
parentAuthorDisplay = parentAuthor.toBestDisplayName(),
onClick = { nav.nav(routeFor(parentAuthor)) },
)
Spacer(modifier = HalfVertSpacer)
val canShowReply by
remember(note) {
derivedStateOf {
noteEvent is BaseThreadedEvent && !makeItShort && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
}
}
val parentNote = remember(note) { replyingDirectlyTo(note, LocalCache) }
if (parentNote != null && canShowReply) {
when (unPackReply) {
ReplyRenderType.FULL -> {
ReplyNoteComposition(parentNote, backgroundColor, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
}
ReplyRenderType.LINE -> {
// Zap receipts are signed by the recipient's lightning provider;
// label the reply with the zap sender instead of the service key.
val zapSender =
if (parentNote.event is LnZapEvent) {
observeZapSender(parentNote, accountViewModel).value
} else {
null
}
val parentAuthor = zapSender ?: parentNote.author
if (parentAuthor != null) {
ReplyToLabel(
parentAuthorDisplay = parentAuthor.toBestDisplayName(),
onClick = { nav.nav(routeFor(parentAuthor)) },
)
Spacer(modifier = HalfVertSpacer)
}
}
}
} else if (!makeItShort && noteEvent is CommentEvent) {
// No in-cache parent note: this comment answers a scope rather than a note.
DisplayCommentScope(noteEvent, accountViewModel, nav)
}
} else if (!makeItShort && noteEvent is CommentEvent) {
// No in-cache parent note: this comment answers a scope rather than a note.
DisplayCommentScope(noteEvent, accountViewModel, nav)
}
}
@@ -194,29 +199,33 @@ fun RenderTextEvent(
val tags =
remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
quotesLeft = quotesLeft,
modifier = Modifier.fillMaxWidth(),
tags = tags,
backgroundColor = backgroundColor,
id =
if (editState.value is GenericLoadable.Loaded) {
(editState.value as GenericLoadable.Loaded<EditState>)
.loaded.modificationToShow.value
?.idHex ?: note.idHex
} else {
note.idHex
},
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
TracedComposition(NoteTrace.TXT_RICHTEXT) {
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview && !makeItShort,
quotesLeft = quotesLeft,
modifier = Modifier.fillMaxWidth().tracedDraw(NoteTrace.DRAW_RICHTEXT),
tags = tags,
backgroundColor = backgroundColor,
id =
if (editState.value is GenericLoadable.Loaded) {
(editState.value as GenericLoadable.Loaded<EditState>)
.loaded.modificationToShow.value
?.idHex ?: note.idHex
} else {
note.idHex
},
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
if (noteEvent.hasHashtags()) {
DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav)
TracedComposition(NoteTrace.TXT_HASHTAGS) {
DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav)
}
}
}
}
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -43,6 +42,7 @@ import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -81,7 +81,9 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.LocalFeedReactionPainters
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.rememberFeedReactionPainters
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.NewGeoPostButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.NewHashtagPostButton
@@ -423,24 +425,29 @@ fun FeedLoaded(
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
if (liveSection != null) {
item {
DisplayLiveBubbles(liveSection, accountViewModel, nav)
// One VectorPainter per reaction icon for the whole list rather than one per card. Provided
// around the feed only, because a painter caches its raster by draw size and these icons appear
// at other sizes on other screens. See FeedReactionPainters.
val reactionPainters = rememberFeedReactionPainters()
CompositionLocalProvider(LocalFeedReactionPainters provides reactionPainters) {
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
if (liveSection != null) {
item {
DisplayLiveBubbles(liveSection, accountViewModel, nav)
}
}
}
itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { _, item ->
Row(
Modifier
.fillMaxWidth()
.animateItem(),
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { _, item ->
// No wrapper Row: it held a single child that already fills the width, above a
// custom single-pass Layout built to avoid extra nodes. NoteCompose puts this
// modifier straight onto NoteComposeLayout (or onto BlankNote while the event
// loads, which needs the fillMaxWidth to keep its width).
NoteCompose(
item,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().animateItem(),
routeForLastRead = routeForLastRead,
isBoostedNote = false,
isHiddenFeed = items.showHidden,
@@ -448,11 +455,11 @@ fun FeedLoaded(
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(
thickness = DividerThickness,
)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
@@ -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,47 @@
/*
* 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.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
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
/**
* 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 }
@Composable
fun amethystIconsFontFamily(): FontFamily =
LocalAmethystIconsFontFamily.current ?: run {
val font = Font(resource = Res.font.amethyst_icons)
remember { FontFamily(font) }
}
@@ -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
@@ -46,9 +46,11 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
@@ -246,6 +248,33 @@ private fun HashTagText(
)
}
/**
* Inputs that fully determine the width of a space. Every note in a feed shares the same ones.
*/
private data class SpaceWidthKey(
val fontFamilyResolver: FontFamily.Resolver,
val density: Float,
val fontScale: Float,
val layoutDirection: LayoutDirection,
val textStyle: TextStyle,
)
/**
* Single-entry memo across composables.
*
* `remember` alone caches per call site, and every note card is its own call site — so a feed
* scroll built a [TextMeasurer] and shaped a space glyph once per card to arrive at the same
* number every time. Font resolution plus `Paragraph` construction is not free on a slow device,
* and it lands squarely in the layout path.
*
* A one-entry memo is enough because a feed renders every note in the same style at the same
* density; the key changes only on a theme, font-scale or locale-direction change, when
* recomputing once is exactly right. The read/write race is benign: two threads racing produce
* the same value, and the worst case is a redundant measure.
*/
private var spaceWidthKey: SpaceWidthKey? = null
private var spaceWidthValue: Dp = 0.dp
/** Width of a single space in [textStyle], used to space FlowRow words. */
@Composable
fun measureSpaceWidth(textStyle: TextStyle): Dp {
@@ -253,11 +282,26 @@ fun measureSpaceWidth(textStyle: TextStyle): Dp {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
return remember(fontFamilyResolver, density, layoutDirection, textStyle) {
val widthPx =
TextMeasurer(fontFamilyResolver, density, layoutDirection, 1)
.measure(" ", textStyle)
.size
.width
with(density) { widthPx.toDp() }
val key =
SpaceWidthKey(
fontFamilyResolver,
density.density,
density.fontScale,
layoutDirection,
textStyle,
)
if (key == spaceWidthKey) {
spaceWidthValue
} else {
val widthPx =
TextMeasurer(fontFamilyResolver, density, layoutDirection, 1)
.measure(" ", textStyle)
.size
.width
val width = with(density) { widthPx.toDp() }
spaceWidthValue = width
spaceWidthKey = key
width
}
}
}
+2
View File
@@ -22,6 +22,7 @@ coil = "3.5.0"
composeBom = "2026.08.00"
composeRuntimeAnnotation = "1.12.0"
coreKtx = "1.19.0"
tracing = "1.3.0"
datastore = "1.2.1"
devWhyolegCryptography = "0.6.0"
espressoCore = "3.7.0"
@@ -129,6 +130,7 @@ androidx-compose-runtime-annotation = { group = "androidx.compose.runtime", name
androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" }
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "androidxExifinterface" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-tracing = { group = "androidx.tracing", name = "tracing", version.ref = "tracing" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+148
View File
@@ -0,0 +1,148 @@
# `:macrobenchmark` — feed rendering measurement rig
Measures what a feed scroll actually costs, on a real device, per sub-component.
Built to study `NoteCompose`; kept because the traps below cost more time to find
than the code did.
## Running
```bash
ANDROID_SERIAL=<serial> ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \
-Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true \
-Pandroid.testInstrumentationRunnerArguments.class=\
com.vitorpamplona.amethyst.macrobenchmark.FeedScrollBenchmark#scrollAttribution
```
`leaveApksInstalledAfterRun` is **mandatory**. Without it AGP uninstalls the app when
the run finishes, deleting its data — the logged-in account included. The next run then
finds a login screen instead of a feed, having silently destroyed the state that made
the previous run possible.
Tests: `scrollLive`, `scrollQuiet` (network cut once the feed fills), `scrollAttribution`
(per-sub-component composition + draw), `scrollPhases` (frame phases and main-thread
costs — `postAndWait`, `Compose:onForgotten`, `AndroidOwner:onTouch`, …).
## Methodology, learned the hard way
**A tight within-arm spread proves nothing about a between-arm delta.** The single most
expensive mistake here. A result with a 0.9% baseline spread was still an artifact,
because the two arms ran at different times against a *live* feed and rendered different
notes. Always interleave arms (A,B,A,B) and always serve a frozen corpus —
`tools/feed-bench-corpus/setup.sh`.
**Check the controls before the result.** Every ablation should leave some section it
cannot possibly affect. If those move, the run is drift and the delta is unreadable.
**Verify the change is actually active.** A `CompositionLocal` that resolves to null, or
a flag applied to the wrong one of six same-named `FeedLoaded` functions, makes a fix a
silent no-op that looks exactly like "no effect". Emit a trace section from inside the
new code path and assert a non-zero count.
**Per-composition section metrics need a synthetic corpus; anything involving engagement
counts needs a real one.** Real notes have variable heights (images, `Show More`,
reposts), so a fixed-distance scroll composes a different number of cards each run —
section metrics swing 832% while frame metrics stay within 0.1%. Judge on frame metrics
unless composition counts are identical across runs.
**Don't touch the device while a run is in flight.** Driving the UI mid-run killed one
arm outright and corrupted another.
## What the numbers said (SM-T220, for orientation)
- The main thread is blocked in `postAndWait` on the RenderThread for roughly **two
thirds of every frame**, and stayed within ~2% of that through six different
ablations. Card composition is ~3% of frame CPU, so composition wins have a low
ceiling: a 41% composition change moved frame P90 by 2.3%.
- Overdraw *depth* is not the cost. The content area measured 4×+, and removing a
full-screen fill changed frame time by nothing — while clearing it in the theme made
the window non-opaque and measured ~17% **worse** at P90.
- Still unexplained and worth a look: RenderThread `flush commands` at ~7.5 ms/frame,
"Slow issue draw commands" on ~53% of frames, and tiny texture uploads costing
absurdly much (a 360×17 texture at 37.6 ms).
## Release footprint — read before merging
The trace markers are gated on `BuildConfig.TRACE_NOTE_RENDER`, false outside the
`benchmark` build type, so **they never execute** in debug or release. They are **not**
stripped, however: the marker strings, `NoteRenderTrace`, `TracedComposition`,
`ProbedCollect` and a reference to `androidx.tracing.Trace` are all present in the R8'd
release DEX (verified by grepping it). Likely because `TracedComposition` is a
`@Composable inline` function the Compose plugin transforms before R8 sees it.
Runtime cost is nil; APK footprint is not. Before merging this to `main`, move the
tracer behind a source-set split (a no-op implementation for `debug`/`release`, the real
one only in `benchmark`) so release genuinely contains none of it.
## The probes are broken on purpose
`PROBE_NO_FLOW_STATE` kills every live counter, `PROBE_NO_RX_ICONS` blanks the icons,
`PROBE_NO_RX_CLICKABLE` makes buttons untappable. They exist to be measured and thrown
away. All default to false.
## Normalize `Sum` metrics by their count — always
`TraceSectionMetric(Mode.Sum)` reports the summed duration of every matching slice
in an iteration. That sum is comparable across arms **only if every iteration
renders the same number of cards.** On a corpus of real notes it does not: card
heights vary, so a fixed-distance swipe crosses a different number of cards each
run. Three arms measured here — two of them running *identical code* — reported
`NoteCardCount` of 10, 13 and 8.
The summed metrics therefore drifted 3673% between identical arms, which is far
larger than any effect worth shipping, and made a real result invisible. Dividing
`<Section>SumSumMs` by `<Section>SumCount` removes the denominator:
```bash
python3 macrobenchmark/tools/normalize.py path/to/*.json
```
In the run that motivated this, that single step took `DrawAuthor` from an
unreadable 41.8% apparent swing to a **4.6% drift floor with a clear +74% effect**.
Read the per-occurrence table, never the raw sums.
## Warm the image cache before measuring
Freezing the *events* (`tools/feed-bench-corpus/setup.sh`) is not enough. Real
notes carry remote image URLs that resolve asynchronously, so card heights keep
moving until they land. The benchmark now scrolls the whole corpus once, with the
network still up, before cutting the radios — see `WARMUP_SCROLLS`.
## Analysing a trace
`tools/rt_analyze.sh <trace.perfetto-trace>` decomposes a trace with Perfetto's
`trace_processor`: RenderThread and main-thread **self**-time by slice (summing by
name across depths double-counts), texture/atlas uploads, and the children of the
`animation` slice. Prefer this to parsing `atrace` text — text parsing is what
previously credited macrobenchmark's own `reportMetricsWithPresentTime` to the app.
## Use the uniform corpus (`tools/feed-bench-corpus/seed-uniform.sh`)
The real-event corpus cannot give comparable arms. The app persists no events, so every
launch re-downloads and renders a *different subset* in a different order; two cold
launches shared only half their visible notes, and a longer settle made it worse. Combined
with notes of wildly different heights (only 28 of 105 real notes are text-only, spanning
11-510 chars), a fixed-distance scroll crosses a different number of cards every arm.
`seed-uniform.sh` serves notes that are all the *same height*, so the scroll crosses the
same number of cards no matter which notes loaded. Measured effect on two arms of
identical code:
| | real corpus | uniform corpus |
|---|---|---|
| NoteCardSumCount | 14 vs 17 | **18 vs 18** |
| frameDurationCpuMs P90 drift | 10.1% | **1.4%** |
| frameOverrunMs P90 drift | 20.0% | **2.8%** |
Requirements, each learned the hard way — see the script's comments:
- Bodies must be unique but the same length; byte-identical content is collapsed by the
app's duplicate/spam filter (60 identical notes rendered as ONE card).
- Every note needs nonzero reactions, or the animation under test never constructs.
- No kind-6 reposts: they are their own card shape and, without the reposted event inline
(NIP-18), render "Event is loading or can't be found in your relay list".
- All authors share one `picture` URL. Warm it once with the network up, then run offline:
one cached bitmap, identical avatar path per card, and the real image path still runs.
Run the app **offline** (the benchmark cuts the radios before launch; the corpus arrives
over `adb reverse`, which is USB and unaffected), and set the bench account's Tor engine
to **Off** or a blocking "Tor isn't connecting" dialog swallows every swipe.
+56
View File
@@ -0,0 +1,56 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.test")
}
android {
namespace = "com.vitorpamplona.amethyst.macrobenchmark"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
// Macrobenchmark needs API 29+; frame metrics from `dumpsys gfxinfo` need 29+.
minSdk = 29
targetSdk = libs.versions.android.targetSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// The SM-T220 and the Pixel 9 AVD are both legitimate targets here: the emulator
// is suppressed from erroring out, and the tablet is often not fully charged.
testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] =
"EMULATOR,LOW-BATTERY,DEBUGGABLE,NOT-PROFILEABLE,ENG-BUILD"
// :amethyst has a `channel` dimension (play/fdroid). Rendering does not differ by
// store, so measure against play.
missingDimensionStrategy("channel", "play")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
// Run against the profileable, R8-optimized `benchmark` variant of the app. Measuring
// a debug build tells you about ART's interpreter, not about the app's own code.
buildTypes {
create("benchmark") {
isDebuggable = true
signingConfig = signingConfigs.getByName("debug")
}
}
targetProjectPath = ":amethyst"
experimentalProperties["android.experimental.self-instrumenting"] = true
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
dependencies {
implementation(libs.androidx.junit)
implementation(libs.androidx.runner)
implementation(libs.androidx.uiautomator)
implementation(libs.androidx.benchmark.macro.junit4)
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Macrobenchmark drives the app from a separate, self-instrumenting process. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
</manifest>
@@ -0,0 +1,327 @@
/*
* 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.macrobenchmark
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.ExperimentalMetricApi
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.Metric
import androidx.benchmark.macro.TraceSectionMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Frame-timing of the home feed scroll — i.e. of [NoteCompose] rendering, since the feed's
* `LazyColumn` item body is one `NoteCompose` per note.
*
* Two variants, because the feed's frame budget is contended by two very different things:
*
* - [scrollLive] scrolls while the relay pool is still delivering. This is what a user
* actually experiences, but the number is a mix of rendering cost and the ingest storm
* (relay parse + `LocalCache` writes + the GC pressure they create) stealing CPU from
* the main thread.
* - [scrollQuiet] cuts the network *after* the feed has filled, so ingest stops and the
* same already-cached notes are re-rendered. That isolates the composition/layout/draw
* cost of the card itself.
*
* The gap between the two is the ingest interference; the [scrollQuiet] number is the
* ceiling that rendering work alone can move.
*
* ## Running
*
* ```
* ANDROID_SERIAL=<serial> ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \
* -Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true
* ```
*
* **The `leaveApksInstalledAfterRun` flag is mandatory, not optional.** Without it AGP
* uninstalls the target APK when the run finishes, which deletes the app's data — the
* logged-in account and the selected feed included. The next run then launches a
* first-boot app, finds a login screen instead of a feed, and fails in `setupBlock`
* (having silently destroyed the account that made the previous run possible).
*
* The device must already be logged in and sitting on a feed with content. A throwaway
* account on the **Global** feed is the reproducible choice: it needs no follows, so the
* same content type is available on every device.
*/
@OptIn(ExperimentalMetricApi::class)
@RunWith(AndroidJUnit4::class)
class FeedScrollBenchmark {
@get:Rule
val rule = MacrobenchmarkRule()
@Test
fun scrollLive() = scrollFeed(quiet = false)
@Test
fun scrollQuiet() = scrollFeed(quiet = true)
/**
* Same quiet scroll, but reporting the per-sub-component composition time emitted by
* `TracedComposition` in the app. Requires the `benchmark` build type (which sets
* `TRACE_NOTE_RENDER = true`); on any other build the sections are absent and every
* value reads zero.
*
* `Mode.Sum` is the number that matters: total ms spent composing that part of the
* card across the whole scroll, which is what a code change has to move.
*/
@Test
fun scrollAttribution() = scrollFeed(quiet = true, metrics = frameMetrics() + sectionMetrics())
/**
* Splits a frame into its phases rather than its composables.
*
* The card's composition is only a few percent of frame CPU on a slow device, so the rest has to
* be somewhere the `TracedComposition` markers cannot see: the measure/layout/draw traversal.
* These sections are emitted by `ViewRootImpl` and Compose UI themselves — no app instrumentation
* — so this works on any build.
*
* `Mode.Sum` per iteration is the useful figure: total ms the scroll spent in each phase.
*/
@Test
fun scrollPhases() = scrollFeed(quiet = true, metrics = frameMetrics() + phaseMetrics())
private fun frameMetrics(): List<Metric> = listOf(FrameTimingMetric())
private fun sectionMetrics(): List<Metric> =
TRACED_SECTIONS.flatMap { section ->
listOf(
TraceSectionMetric(section, TraceSectionMetric.Mode.Sum, label = "${section.removePrefix("Amethyst:")}Sum"),
TraceSectionMetric(section, TraceSectionMetric.Mode.Count, label = "${section.removePrefix("Amethyst:")}Count"),
)
}
private fun phaseMetrics(): List<Metric> =
FRAME_PHASES.flatMap { section ->
listOf(
TraceSectionMetric(section, TraceSectionMetric.Mode.Sum, label = "${section.replace(':', '_').replace('#', '_')}Sum"),
TraceSectionMetric(section, TraceSectionMetric.Mode.Count, label = "${section.replace(':', '_').replace('#', '_')}Count"),
)
}
private fun scrollFeed(
quiet: Boolean,
metrics: List<Metric> = frameMetrics(),
) {
var prepared = false
try {
rule.measureRepeated(
packageName = PACKAGE,
metrics = metrics,
// Full AOT, deliberately: this study compares *rendering* costs across code
// changes, and JIT warm-up variance would swamp the differences being looked
// for. Absolute numbers therefore read slightly better than a shipped build.
compilationMode = CompilationMode.Full(),
iterations = ITERATIONS,
setupBlock = {
if (!prepared) {
// Cut the radios BEFORE the app launches, not after the feed loads.
//
// The corpus relay is reached over `adb reverse` (USB loopback), which
// is unaffected by wifi/data, so the app still ingests the fixed corpus
// -- and *only* the corpus. Cutting the network after the settle, as
// this used to, left the app free to pull live notes from ~190 real
// relays while the feed was being built. Those differ on every launch,
// which is why each arm was internally deterministic (identical card
// counts across all 10 iterations) yet landed on a different constant
// from its neighbours -- 8 vs 12 vs 13 NoteCards -- making three A/B
// runs unreadable.
if (quiet) setNetworkEnabled(false)
pressHome()
startActivityAndWait()
// Let relays connect and fill the feed. Everything measured afterwards
// re-renders notes that are already in LocalCache.
device.wait(Until.hasObject(By.scrollable(true)), FEED_APPEAR_TIMEOUT_MS)
check(device.hasObject(By.scrollable(true))) {
"No scrollable feed appeared — is an account logged in with a non-empty feed?"
}
Thread.sleep(INGEST_SETTLE_MS)
// Warm the image cache before measuring anything. A corpus of real notes
// carries remote image URLs, and those resolve asynchronously: until they
// do, card heights keep changing, so a fixed-distance scroll composes a
// different number of cards on every run. That alone widened section
// spreads from ~1-3% to 17-28% and made an A/B unreadable. Scrolling the
// whole corpus once, with the network still up, settles every height and
// fills Coil's cache; only then is it safe to cut the network.
repeat(WARMUP_SCROLLS) { swipeFeed(down = true) }
device.waitForIdle()
repeat(WARMUP_SCROLLS) { swipeFeed(down = false) }
device.waitForIdle()
Thread.sleep(IMAGE_SETTLE_MS)
prepared = true
}
// Every iteration starts from the same place in the feed, so each one
// renders a comparable set of cards.
repeat(SCROLLS) { swipeFeed(down = false) }
device.waitForIdle()
},
) {
repeat(SCROLLS) { swipeFeed(down = true) }
device.waitForIdle()
}
} finally {
// Restore the radios even when an iteration throws, so a failed run does not
// leave the device offline for the next one.
if (quiet) setNetworkEnabled(true)
}
}
private val device: UiDevice
get() = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
/**
* Drags the feed by a fixed fraction of the screen using raw coordinates.
*
* Deliberately not `UiObject2.scroll()`: that resolves an accessibility node up front
* and then throws [androidx.test.uiautomator.StaleObjectException] the moment the list
* recycles it mid-gesture, which a scrolling feed does constantly. Raw coordinates also
* make the gesture identical on every run and every device (same fraction of the
* screen, same step count), which is what makes iterations comparable.
*
* [SWIPE_STEPS] is high on purpose: many small steps produce a steady drag rather than
* a fling, so the measured frames are the ones that actually compose new cards instead
* of a short burst followed by decelerating scroll.
*/
private fun swipeFeed(down: Boolean) {
val x = device.displayWidth / 2
val top = (device.displayHeight * SWIPE_TOP_FRACTION).toInt()
val bottom = (device.displayHeight * SWIPE_BOTTOM_FRACTION).toInt()
if (down) {
device.swipe(x, bottom, x, top, SWIPE_STEPS)
} else {
device.swipe(x, top, x, bottom, SWIPE_STEPS)
}
}
private fun setNetworkEnabled(enabled: Boolean) {
val state = if (enabled) "enable" else "disable"
device.executeShellCommand("svc wifi $state")
device.executeShellCommand("svc data $state")
// The radios take a moment to actually go down; without this the first measured
// iteration still sees in-flight relay traffic.
Thread.sleep(NETWORK_TOGGLE_SETTLE_MS)
}
companion object {
const val PACKAGE = "com.vitorpamplona.amethyst.benchmark"
/** Enough iterations for a stable median without pushing the tablet into an lmkd kill. */
const val ITERATIONS = 10
const val SCROLLS = 4
/** Gesture spans the middle 60% of the screen, clear of the status and nav bars. */
const val SWIPE_TOP_FRACTION = 0.2
const val SWIPE_BOTTOM_FRACTION = 0.8
/** ~5 ms per step, so one swipe is a ~300 ms steady drag rather than a fling. */
const val SWIPE_STEPS = 60
const val FEED_APPEAR_TIMEOUT_MS = 30_000L
/**
* Long enough for relays to deliver a screenful of notes, short enough to stay under
* the memory ceiling of a 3 GB SM-T220, which lmkd-kills a release build around 1.85 GB.
*/
const val INGEST_SETTLE_MS = 20_000L
const val NETWORK_TOGGLE_SETTLE_MS = 3_000L
/** Enough passes to touch every card in the corpus so all images resolve. */
const val WARMUP_SCROLLS = 24
/** Lets the last decodes land after the warm-up gesture stops. */
const val IMAGE_SETTLE_MS = 8_000L
/**
* Frame-phase sections emitted by the platform and by Compose UI itself. Nothing in the app
* has to be instrumented for these; absent sections simply report zero.
*/
val FRAME_PHASES =
listOf(
"Choreographer#doFrame",
"traversal",
"measure",
"layout",
"draw",
"AndroidOwner:measureAndLayout",
"Compose:recompose",
"Compose:applyChanges",
// RenderThread: DrawFrame executes the display list the UI thread recorded, and is
// where the other half of frame CPU lives on a draw-bound device.
"DrawFrame",
"syncFrameState",
"flush commands",
"eglSwapBuffersWithDamageKHR",
// Main-thread costs an atrace capture showed dominating a scroll, none of which
// the composition/draw markers can see.
"postAndWait",
"Compose:onForgotten",
"Compose:onRemembered",
"AndroidOwner:onTouch",
"TextStringSimpleNode::measure",
"TextAnnotatedStringNode:measure",
"TextLayout:initLayout",
"animation",
"Amethyst:DrawAuthor",
)
/** Must stay in sync with `NoteTrace` in the app. */
val TRACED_SECTIONS =
listOf(
"Amethyst:NoteCard",
"Amethyst:AuthorImages",
"Amethyst:FirstUserInfoRow",
"Amethyst:SecondUserInfoRow",
"Amethyst:NoteContent",
"Amethyst:ReactionsRow",
// Drill-down inside the two most expensive slots.
"Amethyst:RxIndicators",
"Amethyst:RxZapraiser",
"Amethyst:RxReply",
"Amethyst:RxBoost",
"Amethyst:RxLike",
"Amethyst:RxZap",
"Amethyst:RxShare",
"Amethyst:RxPay",
"Amethyst:TxtReplyPreview",
"Amethyst:TxtRichText",
"Amethyst:TxtHashtags",
// Draw phase (Modifier.tracedDraw).
"Amethyst:DrawReactions",
"Amethyst:DrawAuthor",
"Amethyst:DrawFirstRow",
"Amethyst:DrawRichText",
// Non-zero count proves the shared-painter path is actually being taken.
"Amethyst:SharedPainter",
)
}
}
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Compare macrobenchmark arms by *per-occurrence* section time.
A TraceSectionMetric in Mode.Sum reports the summed duration of every matching
slice in an iteration. That sum is only comparable across arms if each iteration
renders the same number of cards -- and on a corpus of real notes it does not:
card heights vary, so a fixed-distance swipe crosses a different number of cards
every run. Measured counts ranged 8..13 for NoteCard in three arms of identical
code, which alone moved the summed metrics by 36-73%.
Dividing SumSumMs by SumCount removes that denominator. In the run that motivated
this script it took DrawAuthor from unreadable (41.8% apparent swing) to a 4.6%
drift floor with a clear +74% effect.
Usage: normalize.py armA.json armB.json [...] (arm name = file stem)
"""
import json, sys
def load(path):
b = json.load(open(path))["benchmarks"][0]
out = {}
for src in ("metrics", "sampledMetrics"):
for k, v in b.get(src, {}).items():
if isinstance(v, dict) and "median" in v:
out[k] = v["median"]
return out
def main(paths):
arms = {p.split("/")[-1].removesuffix(".json"): load(p) for p in paths}
names = list(arms)
secs = sorted({k[:-8] for k in arms[names[0]] if k.endswith("SumSumMs")})
print(f"{'section (ms per occurrence)':<26}" + "".join(f"{n:>10}" for n in names))
print("-" * (26 + 10 * len(names)))
for s in secs:
vals = []
for n in names:
ms, cnt = arms[n].get(s + "SumSumMs"), arms[n].get(s + "SumCount")
vals.append(ms / cnt if ms is not None and cnt else None)
if any(v is None for v in vals):
continue
print(f"{s:<26}" + "".join(f"{v:>10.3f}" for v in vals))
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
main(sys.argv[1:])
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# Decompose a macrobenchmark perfetto trace. $1 = trace file.
TP=/private/tmp/claude-501/-Users-vitor-Documents-workspace-Amethyst/c1db9f48-dfa5-437b-acc2-a33fcb5fe512/scratchpad/trace_processor
T="$1"
echo "############ RenderThread: self-time by slice name ############"
$TP -Q "
with rt as (
select s.id, s.name, s.dur, s.parent_id
from slice s join thread_track tt on s.track_id=tt.id join thread t using(utid)
where t.name like '%RenderThread%'
),
child as (select parent_id, sum(dur) d from rt where parent_id is not null group by parent_id)
select rt.name, count(*) n, round(sum(rt.dur - coalesce(child.d,0))/1e6,1) self_ms,
round(sum(rt.dur)/1e6,1) total_ms
from rt left join child on child.parent_id = rt.id
group by rt.name order by self_ms desc limit 25;" "$T" 2>/dev/null
echo "############ Texture / atlas uploads (name + dimensions) ############"
$TP -Q "
select s.name, count(*) n, round(sum(s.dur)/1e6,2) ms, round(avg(s.dur)/1e3,1) avg_us
from slice s join thread_track tt on s.track_id=tt.id join thread t using(utid)
where t.name like '%RenderThread%'
and (s.name like '%upload%' or s.name like '%Texture%' or s.name like '%Atlas%'
or s.name like '%glyph%' or s.name like '%Glyph%')
group by s.name order by ms desc limit 30;" "$T" 2>/dev/null
echo "############ Main thread: self-time by slice name ############"
$TP -Q "
with mt as (
select s.id, s.name, s.dur, s.parent_id
from slice s join thread_track tt on s.track_id=tt.id join thread t using(utid)
where t.is_main_thread = 1
),
child as (select parent_id, sum(dur) d from mt where parent_id is not null group by parent_id)
select mt.name, count(*) n, round(sum(mt.dur - coalesce(child.d,0))/1e6,1) self_ms
from mt left join child on child.parent_id = mt.id
group by mt.name order by self_ms desc limit 25;" "$T" 2>/dev/null
echo "############ Children of 'animation' (the 837ms mystery) ############"
$TP -Q "
select c.name, count(*) n, round(sum(c.dur)/1e6,1) ms
from slice p join slice c on c.parent_id = p.id
where p.name = 'animation'
group by c.name order by ms desc limit 20;" "$T" 2>/dev/null
+1
View File
@@ -34,6 +34,7 @@ include(":amethyst")
include(":nappletHost")
include(":benchmark")
include(":baselineprofile")
include(":macrobenchmark")
include(":quartz")
include(":geode")
include(":commons")
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Uniform corpus: every card identical in structure so a fixed-distance scroll
# crosses the same number of cards regardless of which notes loaded or in what
# order. That is what the real corpus could not give us -- the app persists no
# events, so each launch re-downloads and renders a different subset.
#
# Deliberately NOT zero-reaction: the previous synthetic corpus pinned heights but
# left every count at 0, so SlidingAnimationCount rendered nothing and the code
# under test never constructed. Every note here gets identical real reactions.
set -uo pipefail
ROOT=/Users/vitor/Documents/workspace/Amethyst
SP=/private/tmp/claude-501/-Users-vitor-Documents-workspace-Amethyst/c1db9f48-dfa5-437b-acc2-a33fcb5fe512/scratchpad
AMY="$ROOT/cli/build/install/amy/bin/amy"
DB="$SP/uniform.db"
RELAY="ws://127.0.0.1:7447"
NOTES=${NOTES:-60}
# Notes must be RECENT. The home feed only surfaces recent notes: seeded at a
# fixed 2025 timestamp, 59 of 60 notes never appeared and the feed rendered a
# single card. Spread them over the last hour instead.
NOW=$(date +%s)
BASE=$((NOW - 3600))
amy() { HOME="$SP/amyhome" "$AMY" "$@"; }
# same picture for every author => one cached bitmap, identical avatar path per card
PIC="https://robohash.org/benchshared.png"
# Each body must be UNIQUE but the SAME LENGTH. Byte-identical content is
# collapsed by the app's duplicate/spam filter -- 60 identical notes rendered as
# exactly ONE card. A fixed-width numeric suffix keeps every card the same height
# while making the content distinct.
BODY="alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango"
# Kill whatever holds the port, not just the relay we expect: a previous run can
# leave a relay bound to 7447 serving a different db, and then every publish
# silently goes nowhere (the symptom is "FAILED to parse id at note 0").
echo "stopping any relay on 7447…"
for pid in $(lsof -tiTCP:7447 -sTCP:LISTEN 2>/dev/null); do kill "$pid" 2>/dev/null; done
sleep 4
if lsof -tiTCP:7447 -sTCP:LISTEN >/dev/null 2>&1; then echo "port 7447 still busy, aborting"; exit 1; fi
rm -f "$DB"*
echo "starting relay on $RELAY (db: uniform.db)"
amy --account bench1 serve --db "$DB" --port 7447 > "$SP/uniform-relay.log" 2>&1 &
echo $! > "$SP/uniform-relay.pid"; sleep 10
declare -a PK
for n in 1 2 3 4 5; do
PK[$n]=$(amy --account "bench$n" whoami 2>/dev/null | awk '/^hex:/{print $2}')
amy --account "bench$n" event --kind 0 --created-at $((BASE - 100 + n)) \
--content "{\"name\":\"Bench $n\",\"picture\":\"$PIC\"}" --relay "$RELAY" >/dev/null 2>&1
done
echo "authors seeded: ${PK[1]:0:8}${PK[5]:0:8}"
for i in $(seq 0 $((NOTES-1))); do
a=$(( i % 5 + 1 ))
out=$(amy --account "bench$a" event --kind 1 --created-at $((BASE + i*50)) \
--content "$BODY $(printf '%04d' $i)" --relay "$RELAY" 2>/dev/null)
id=$(printf '%s' "$out" | grep -oE '[0-9a-f]{64}' | head -1)
[ -z "$id" ] && { echo "FAILED to parse id at note $i"; printf '%s\n' "$out" | head -5; exit 1; }
# identical engagement on every note: 2 likes + 1 repost
for r in 1 2; do
b=$(( (i + r) % 5 + 1 ))
# fixed old timestamp: engagement must not reorder the feed or create
# "now" cards at the top on every seeding run
amy --account "bench$b" event --kind 7 --content "+" --created-at $((BASE + i*50 - 10*r)) \
--tags "[[\"e\",\"$id\"],[\"p\",\"${PK[$a]}\"]]" --relay "$RELAY" >/dev/null 2>&1
done
# NO kind-6 reposts. A repost is its own feed entry with a different card shape,
# and with an empty content it renders "Event is loading or can't be found in
# your relay list" (NIP-18 wants the reposted event JSON inline). Either way it
# breaks the constant-height property this corpus exists to provide. Cost: boost
# counts stay 0, so only the like counter exercises SlidingAnimationCount.
[ $((i % 10)) -eq 0 ] && echo " note $i/$NOTES"
done
echo "DONE: $(amy --account bench1 count --kind 1 --relay "$RELAY" --timeout 15 2>/dev/null | awk '/total/{print $2}') notes"
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Fixed-corpus relay rig for feed rendering benchmarks.
#
# Why: FeedScrollBenchmark's numbers are only comparable if every run renders the
# same notes. Against a live Global feed they do not — measured drift between two
# identical baseline runs reached 73-97% on avatar metrics and moved DrawReactions
# from 2.78 to 1.70 with no code change, because card heights (how many cards a
# fixed-distance scroll composes) and the pictureless-author mix (robohash vectors
# are expensive to draw) both follow whatever the firehose happened to deliver.
#
# This serves a frozen corpus instead. NOTE: a *synthetic* corpus (uniform generated
# notes) pins card heights beautifully but is useless for anything involving reaction
# counts — with every count at zero, SlidingAnimationCount renders nothing and the
# AnimatedContent under test is never constructed. Capture real events instead:
#
# amy --account benchN fetch --kind 7,6,9735 --limit 600 --relay <real relays> --json
# -> collect the e-tagged note ids -> fetch those notes -> fetch their kind 0
# -> republish all of it into the local relay
#
# Reactions first, then the notes they point at: notes fetched directly are usually
# too fresh to have any engagement.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
WORK="${WORK:-/tmp/amethyst-feed-corpus}"
AMY="$ROOT/cli/build/install/amy/bin/amy"
RELAY="ws://127.0.0.1:7447"
PORT=7447
mkdir -p "$WORK"
export HOME_ORIG="$HOME"
amy() { HOME="$WORK/amyhome" "$AMY" "$@"; }
if [ ! -x "$AMY" ]; then
echo "building amy…"; (cd "$ROOT" && ./gradlew -q :cli:installDist)
fi
# Throwaway identities, isolated from the developer's real ~/.amy, with the
# plaintext backend so nothing prompts the macOS keychain.
if [ ! -d "$WORK/amyhome" ]; then
mkdir -p "$WORK/amyhome"
for n in 1 2 3 4 5; do
amy --account "bench$n" --secret-backend plaintext init >/dev/null
done
fi
echo "starting relay on $RELAY (db: $WORK/corpus.db)"
amy --account bench1 serve --db "$WORK/corpus.db" --port "$PORT" > "$WORK/relay.log" 2>&1 &
echo $! > "$WORK/relay.pid"
sleep 10
if [ ! -f "$WORK/.seeded" ]; then
echo "seeding corpus…"
# Three authors with pictures, two without: pins how many avatars fall back to a
# generated robohash, which is the dominant swing in DrawAuthor.
for n in 1 2 3; do
amy --account "bench$n" event --kind 0 --created-at "$((1750000000 + n))" \
--content "{\"name\":\"Bench $n\",\"picture\":\"https://robohash.org/bench$n.png\"}" \
--relay "$RELAY" >/dev/null
done
for n in 4 5; do
amy --account "bench$n" event --kind 0 --created-at "$((1750000000 + n))" \
--content "{\"name\":\"Bench $n\"}" --relay "$RELAY" >/dev/null
done
python3 - "$WORK" <<'PY' > "$WORK/notes.sh"
import sys
WORDS = ("alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima "
"mike november oscar papa quebec romeo sierra tango uniform victor whiskey").split()
LENGTHS = [6, 40, 12, 90, 20, 5, 55, 15, 120, 30] # fixed cycle => fixed card heights
for i in range(80):
n = LENGTHS[i % len(LENGTHS)]
body = " ".join(WORDS[(i + j) % len(WORDS)] for j in range(n))
print(f"amy --account bench{i % 5 + 1} event --kind 1 --created-at {1751000000 + i} "
f"--content '{body}' --relay $RELAY >/dev/null")
PY
. "$WORK/notes.sh"
touch "$WORK/.seeded"
fi
echo "corpus: $(amy --account bench1 count --kind 1 --relay "$RELAY" --timeout 15 | awk '/total/{print $2}') notes"
for SERIAL in $(adb devices | awk '/device$/{print $1}'); do
adb -s "$SERIAL" reverse tcp:$PORT tcp:$PORT && echo "adb reverse ready on $SERIAL"
done
cat <<'EOF'
Final step (once per device, by hand):
Amethyst > drawer > Relays > "Local Relays" -> add ws://127.0.0.1:7447 > Save
It MUST go in the "Local Relays" section ("relays running on this device").
That list is device-local. Adding the relay to "Public Outbox/Home Relays"
instead does not stick: the app re-fetches the account's kind-10002 from the
network and a loopback URL does not survive the round trip, so the entry silently
disappears within a minute. `Account.outboxHomeRelays()` unions nip65 +
privateStorage + localRelayList, so a Local Relay is used for reading all the
same.
Two automation traps if you try to script this:
- The nav drawer and the relay settings are overlay windows `uiautomator dump`
does not capture; it returns the feed behind them, so taps look like no-ops.
Drive it from screenshots.
- The relay settings screen nests a scrollable relay list that swallows
flings, so the outer screen stops scrolling partway down.
Then the feed contains exactly the 80 corpus notes, identical on every run.
Stop the relay with: kill $(cat "$WORK/relay.pid")
EOF
+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:])