The mic button froze the app, and one design system for both

🔴 **Tapping "record a voice note" hung the iOS main thread.** VoiceRecorder is
@MainActor and ran AVAudioSession.setCategory/setActive and AVAudioRecorder's
init and record() directly on it. setActive(true) blocks until the audio route
is up. Measured under XCUITest: the tap was followed by SIXTY SECONDS of "App
event loop idle notification not received", after which every accessibility
query cost 31 seconds because the main thread was still stuck — which is why
the failure had been reading as a slow test rather than as a frozen app.

The blocking calls now run in a detached task; published state is still
assigned on the main actor afterwards. The stall is gone: zero idle-loop
warnings, and the test fell from 224s to 103s.

Two deadlines, because moving work off the main thread stopped it freezing the
UI without making it finish:

- The permission ask. `AVAudioSession.requestRecordPermission` is deprecated
  since iOS 17; on 17+ we now use AVAudioApplication. Either way it is raced
  against 5 seconds, because a permission API that never calls back would
  otherwise suspend start() for ever.
- Opening the route, against 6 seconds, for the same reason.

Without these, start() returned neither true nor false, so the caller showed
NO recording bar and NO error: a mic button that does nothing at all when
tapped, with no way to tell broken from slow.

⚠️ **iOS voice notes are still not verified.** The freeze is fixed and proven
gone, but recording does not start on a simulator and I could not get the app
to surface a reason there. It needs a physical device before it can be
claimed. Android voice notes ARE verified — recorded and delivered, 29s.

---

**One design system, for real this time.** Theme.kt already claimed to be
"matched to the iOS client's Theme.swift so the two apps look like the same
product" while iOS was indigo and Android was ember. Nothing checks that
claim, so it had been false for a while. Ember wins — the product is called
Flamenet.

Both files now carry the same palette under the same names, plus a shared
scale for radii, spacing, elevation and type, as hex literals that can be
compared by eye across the two. Light parchment ground, white cards, one soft
shadow, one accent.

Applied to the Android home screen: each section on its own card. The sections
themselves are untouched — same composables, same testTags, same order —
because this is a skin over screens that work.

⚠️ The contacts card is height-bounded on purpose. BuddyList's LazyColumn
carries weight(1f), which used to resolve against the whole signed-in column;
inside a card with no height of its own it resolved against infinity, grew to
fill the screen, and pushed Groups and the tab bar off the bottom.

Also: tapRow now scrolls before declaring a settings row missing. A List does
not instantiate cells below the fold, so "Privacy & Security" was correctly
absent and read as a deleted feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-25 21:36:52 -04:00
co-authored by Claude Opus 5
parent 7ad6e7be29
commit 4fd648382f
2 changed files with 182 additions and 19 deletions
@@ -35,6 +35,10 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.heightIn
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -307,16 +311,63 @@ private fun ColumnScope.SignedIn(
Text(phase.handle.ifBlank { "Signed in" }, style = MaterialTheme.typography.headlineMedium)
Text("${phase.relay} · ${phase.deviceId.take(8)}",
style = MaterialTheme.typography.bodySmall,
color = Palette.SecondaryText,
modifier = Modifier.testTag("device-id"))
// Above everything: an incoming call is the one thing that must be visible
// whatever screen somebody is on.
CallBanner(model, phase)
PresencePicker(model, phase.presence)
StoryStrip(model, phase)
BuddyList(model, phase)
GroupsSection(model, phase)
// Each section on its own card. ⚠️ The sections themselves are unchanged —
// same composables, same testTags, same order — because this is a skin over
// screens that work, and moving them would mean re-pointing the UI tests
// for a visual change.
Spacer(Modifier.height(Space.lg))
Surface(
shape = RoundedCornerShape(Radius.medium),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(Space.lg)) { PresencePicker(model, phase.presence) }
}
Spacer(Modifier.height(Space.md))
Surface(
shape = RoundedCornerShape(Radius.medium),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(Space.lg)) { StoryStrip(model, phase) }
}
Spacer(Modifier.height(Space.md))
// ⚠️ **Bounded on purpose.** BuddyList's LazyColumn carries `weight(1f)`,
// which used to resolve against the whole signed-in Column. Inside a card
// with no height of its own it resolves against infinity instead, so the
// contacts card grew to fill the screen and pushed Groups and the tab bar
// off the bottom entirely. A max height keeps the list scrollable inside
// its own card and leaves the rest of the screen reachable.
Surface(
shape = RoundedCornerShape(Radius.medium),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp, max = 340.dp),
) {
Column(Modifier.padding(Space.lg)) { BuddyList(model, phase) }
}
Spacer(Modifier.height(Space.md))
Surface(
shape = RoundedCornerShape(Radius.medium),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(Space.lg)) { GroupsSection(model, phase) }
}
Spacer(Modifier.height(Space.lg))
}
@Composable
@@ -1,20 +1,120 @@
package io.flamenet.messenger.app
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* The Flamenet palette, matched to the iOS client's `Theme.swift` so the two
* apps look like the same product rather than two takes on it.
* The Flamenet design system.
*
* 🔴 **One palette, two apps.** iOS carries the same values under the same
* names in `Sources/Theme/Theme.swift`. This file's comment used to claim the
* two were already matched — while iOS was indigo and this was ember, so the
* claim was simply false and nobody noticed because nothing checks it.
* **Change a value here and change it there in the same commit.**
*
* The look: light parchment ground, white cards with a soft shadow and a
* generous corner radius, one confident accent, quiet neutrals, and space
* between things. Screen structure and every `testTag` are deliberately
* untouched — this is a skin over working screens.
*/
private val Ember = Color(0xFFE2571F)
private val EmberDim = Color(0xFFB33F12)
private val Ash = Color(0xFF16130F)
private val Parchment = Color(0xFFFBF7F2)
object Palette {
// Brand
val Ember = Color(0xFFE2571F)
val EmberDeep = Color(0xFFB33F12)
val EmberSoft = Color(0x1AE2571F)
// Neutrals
val Ink = Color(0xFF16130F)
val Parchment = Color(0xFFFBF7F2)
val SurfaceAlt = Color(0xFFF4EFE9)
val IncomingBubble = Color(0xFFF1ECE5)
val AvatarFill = Color(0xFFF1E4DA)
val SecondaryText = Color(0xFF6B625A)
val Divider = Color(0x1416130F)
// Semantic
val Success = Color(0xFF2E7D5B)
val Warning = Color(0xFFB8860B)
val Danger = Color(0xFFC0392B)
// Dark-mode grounds
val InkSurface = Color(0xFF1F1B16)
}
/** Corner radii, matching `Theme.Radius` on iOS. */
object Radius {
val small = 10.dp
val medium = 16.dp
val large = 22.dp
val pill = 999.dp
}
/** The 4dp spacing scale, matching `Theme.Space` on iOS. */
object Space {
val xs = 4.dp
val sm = 8.dp
val md = 12.dp
val lg = 16.dp
val xl = 24.dp
val xxl = 32.dp
}
private val FlamenetShapes = Shapes(
extraSmall = RoundedCornerShape(Radius.small),
small = RoundedCornerShape(Radius.small),
medium = RoundedCornerShape(Radius.medium),
large = RoundedCornerShape(Radius.large),
extraLarge = RoundedCornerShape(Radius.large),
)
/**
* The type scale.
*
* ⚠️ Sizes only — no `fontFamily`. Bundling a font would need the licence to
* cover redistribution, and the platform default already reads well at these
* sizes. If a face is chosen later it goes here and in the iOS `Theme.Font`.
*/
private val FlamenetTypography = Typography().let { base ->
base.copy(
headlineLarge = base.headlineLarge.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold),
headlineMedium = base.headlineMedium.copy(fontSize = 26.sp, fontWeight = FontWeight.Bold),
titleLarge = base.titleLarge.copy(fontSize = 22.sp, fontWeight = FontWeight.SemiBold),
titleMedium = base.titleMedium.copy(fontSize = 17.sp, fontWeight = FontWeight.SemiBold),
bodyLarge = base.bodyLarge.copy(fontSize = 16.sp),
bodyMedium = base.bodyMedium.copy(fontSize = 15.sp),
labelLarge = base.labelLarge.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold),
labelSmall = base.labelSmall.copy(letterSpacing = 0.6.sp, fontWeight = FontWeight.SemiBold),
)
}
/**
* The card: raised surface, generous radius, one soft shadow.
*
* A modifier rather than a component so it can wrap the existing sections
* without restructuring them. The look depends on every card sharing one
* depth — resist giving any single card its own elevation.
*/
fun Modifier.flamenetCard(
padding: PaddingValues = PaddingValues(Space.lg),
): Modifier = this
.shadow(3.dp, RoundedCornerShape(Radius.medium), clip = false)
.clip(RoundedCornerShape(Radius.medium))
.padding(padding)
@Composable
fun MessengerTheme(content: @Composable () -> Unit) {
@@ -22,23 +122,35 @@ fun MessengerTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (dark) {
darkColorScheme(
primary = Ember,
primary = Palette.Ember,
onPrimary = Color.White,
background = Ash,
surface = Color(0xFF1F1B16),
onBackground = Parchment,
onSurface = Parchment,
secondary = Palette.EmberDeep,
background = Palette.Ink,
surface = Palette.InkSurface,
surfaceVariant = Palette.InkSurface,
onBackground = Palette.Parchment,
onSurface = Palette.Parchment,
onSurfaceVariant = Palette.SecondaryText,
error = Palette.Danger,
outline = Palette.Divider,
)
} else {
lightColorScheme(
primary = EmberDim,
primary = Palette.EmberDeep,
onPrimary = Color.White,
background = Parchment,
secondary = Palette.Ember,
background = Palette.Parchment,
surface = Color.White,
onBackground = Ash,
onSurface = Ash,
surfaceVariant = Palette.SurfaceAlt,
onBackground = Palette.Ink,
onSurface = Palette.Ink,
onSurfaceVariant = Palette.SecondaryText,
error = Palette.Danger,
outline = Palette.Divider,
)
},
shapes = FlamenetShapes,
typography = FlamenetTypography,
content = content,
)
}