Skip to content

Bump to 0.4.24 — Android Sheet renderer + dismissal wire-shape fix (MOB-104) - #38

Merged
GenericJam merged 2 commits into
masterfrom
fix/mob-104-android-sheet-dismiss
Aug 27, 2026
Merged

Bump to 0.4.24 — Android Sheet renderer + dismissal wire-shape fix (MOB-104)#38
GenericJam merged 2 commits into
masterfrom
fix/mob-104-android-sheet-dismiss

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Unblocks the downstream Android Sheet work. Held from release until now because the headline feature's dismiss contract was broken.

The bug

sendDismissOnce() routed through MobBridge.nativeSendTap, delivering {:tap, tag}. But Mob.UI.sheet/2 documents :on_dismiss as {:dismiss, tag} and iOS delivers exactly that. A screen written to the documented contract never matched: Mob.Screen forwards the unmatched message to handle_info, raising FunctionClauseError and killing the screen — or a catch-all swallowed it, leaving the BEAM unaware the sheet closed while MobSheet's remembered visible was already false, so the sheet was invisible and unre-presentable.

Android had no dismiss sender at all, so this needs the paired mob change (mob_send_dismiss, GenericJam/mob#90).

What's here

  • nativeSendDismiss extern on MobBridge + the matching beam_jni.c thunk; sendDismissOnce switched over.
  • Version bump to 0.4.24 with the full changelog — the entire Android Sheet renderer was sitting unreleased at the already-published 0.4.23, so published mob_new generates apps with no Android sheet while published mob ships Mob.UI.sheet/2.
  • Generated dep floor raised to {:mob, "~> 0.7.31"} (was "~> 0.7"). The generated beam_jni.c calls mob_send_dismiss; the looser constraint resolved an older mob happily and then failed late in mix mob.deploy --android --native.
  • An ### Upgrading note: MobBridge.kt/beam_jni.c are app-owned and never re-rendered, so existing projects need a hand-port. mix mob.doctor now warns (MOB-104: mix mob.doctor detects the stale sheet-dismissal wire shape mob_dev#48).

Test changes worth reading

project_generator_test.exs:791 asserted the buggy string verbatim, so it was pinning the bug green — a correct fix would have failed the suite. It now asserts nativeSendDismiss, refutes the tap form, and covers both halves of the pairing.

An adversarial review also caught a comment of mine claiming the presence assertion guards the MOB-98 declaring-class failure mode. It doesn't — a substring match still passes if the declaration moves. Ownership is pinned by Lint.native_funs_owned_by_mob_bridge/1 via its own test; the comment now says so, so nobody weakens that test believing this one covers it.

Release ordering — hard constraint

mob 0.7.31 must be released first. The beam_jni.c passes clang -fsyntax-only test compiles the rendered thunk against MOB_DIR's mob_beam.h, so this suite fails against a mob without mob_send_dismiss — which is exactly what the pre-push hook hit here. 335/335 pass against a mob carrying the pair.

Also clean: mix format, mix credo --strict, and the ktlint generate-then-lint check.

🤖 Generated with Claude Code

GenericJam and others added 2 commits August 27, 2026 09:12
sendDismissOnce() routed through MobBridge.nativeSendTap, which sends
{:tap, tag}. But Mob.UI.sheet/2 documents :on_dismiss as {:dismiss, tag} and
iOS delivers exactly that, so a screen written to the contract never matched:
Mob.Screen forwards the unmatched message to handle_info, which raises
FunctionClauseError and kills the screen process — or silently drops it with a
catch-all, in which case the BEAM never learns the sheet closed while MobSheet's
remembered `visible` is already false, so the sheet is invisible AND
unre-presentable from that slot.

Android had no dismiss sender at all, so this needs the paired mob change
(mob_send_dismiss + mob_beam.h declaration). Adds here:

- nativeSendDismiss extern on MobBridge. It must stay declared on MobBridge —
  the JNI symbol is Java_<pkg>_MobBridge_nativeSendDismiss and resolution is by
  DECLARING class, not call site. That is the MOB-98 failure mode.
- The matching beam_jni.c thunk calling mob_send_dismiss.
- sendDismissOnce switched over.

Test changes worth calling out: project_generator_test.exs asserted the buggy
shape verbatim ("dismissHandle?.let { MobBridge.nativeSendTap(it) }"), so it was
pinning the bug green — a correct fix would have failed the suite. Now asserts
nativeSendDismiss, refutes the tap form, and covers both halves of the pairing
(the MobBridge extern and the beam_jni thunk), matching how orientation and
connectivity are already covered.

The lockstep is compile-enforced: the "beam_jni.c passes clang -fsyntax-only"
test builds the rendered thunk against MOB_DIR's mob_beam.h, so this suite fails
against a mob without mob_send_dismiss. Verified 335/335 against a mob checkout
carrying the pair. mob must be released first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses the adversarial review of the MOB-104 branch.

- Version bump + CHANGELOG. The whole Android Sheet renderer was sitting
  unreleased on master at the already-published 0.4.23, so published mob_new
  generates apps with NO Android sheet while published mob ships
  Mob.UI.sheet/2. The entry names the wire-shape change explicitly and carries
  an Upgrading section: MobBridge.kt and beam_jni.c are app-owned and never
  re-rendered, so existing projects need a hand-port, and anyone who worked
  around the old bug by matching {:tap, tag} now has a dead clause.

- Generated dep floor raised to {:mob, "~> 0.7.31"} (was "~> 0.7"). The
  generated beam_jni.c calls mob_send_dismiss, which mob only exports from
  0.7.31; the looser constraint resolved an older mob happily and then failed
  late in `mix mob.deploy --android --native` with "call to undeclared
  function 'mob_send_dismiss'" and nothing pointing at the real fix.

- Corrected a misleading test comment claiming the nativeSendDismiss presence
  assertion guards the MOB-98 declaring-class failure mode. It does not — a
  substring match still passes if the declaration moves to another object.
  Ownership is pinned by Lint.native_funs_owned_by_mob_bridge/1 via its own
  generator test, which covers this fun automatically; the comment now says so,
  so nobody weakens that test believing this one covers it.

- MobBridge class doc pointed at mob_nif.c, which no longer exists (it is
  mob_nif.zig), and described only the tap route. Now also documents
  nativeSendDismiss -> mob_send_dismiss -> {:dismiss, tag} and why it
  deliberately doesn't reuse the tap sender.

335 tests (with MOB_DIR pointed at the paired mob), format, credo --strict,
and the ktlint generate-then-lint check all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam

Copy link
Copy Markdown
Owner Author

Device-verified on a physical Moto G power (2021)

The one thing the adversarial review couldn't check from source — that the JNI symbol resolves at runtime and {:dismiss, tag} actually lands in handle_info/2.

Built a probe app from these templates (MOB_NEW_DIR at this branch, MOB_DIR at the paired mob), with a screen that matches both message shapes so a regression would be logged rather than fatal — the whole failure mode is that the wrong shape kills the screen, which would otherwise destroy the evidence.

Step Result
Sheet presents ✅ Material 3 ModalBottomSheet, drag indicator, medium detent
Swipe down to dismiss log: [:dismiss] — the documented clause fired
Wrong shape? :tap_BUG clause never fired
Screen survives ✅ still Sheetprobe.SheetScreen, no FunctionClauseError
Re-present after dismiss open? = true again
Second dismiss log: [:dismiss, :dismiss]

Two dismissals produced exactly two entries, so dismissSent holds exactly-once per presentation. The re-present check matters because the other half of the bug was the BEAM never learning the sheet closed, leaving it invisible and unre-presentable.

Full chain confirmed end to end: onDismissRequestsendDismissOnceMobBridge.nativeSendDismissJava_com_example_sheetprobe_MobBridge_nativeSendDismissmob_send_dismisssendEvent(handle, "dismiss"){:dismiss, tag}.

Unrelated bug found on the way

A freshly generated app can't complete its first Android build: the manifest declares android:icon="@mipmap/ic_launcher" but the generator ships no mipmap resources, so AAPT fails with resource mipmap/ic_launcher not found. Pre-existing on master, not from this PR — filed as MOB-106. I worked around it by copying res/mipmap-* from an existing app.

@GenericJam
GenericJam merged commit f5f3544 into master Aug 27, 2026
4 of 5 checks passed
@GenericJam
GenericJam deleted the fix/mob-104-android-sheet-dismiss branch August 27, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant