All notable changes to mob_new (the project generator for Mob) are documented here.
Format: Keep a Changelog. Versioning: SemVer.
Full module documentation: hexdocs.pm/mob_new.
[Unreleased]
[0.4.31] - 2026-09-04
Performance
The render payload is parsed in one pass on Android.
setRootJsonwent throughJSONObject(json).toMobNode(), which materialised the same payload three times: anorg.jsontree, a second copy of every props map, then the nodes.MobJson.parseNodewalks the text once and buildsMobNodes directly. On a Moto G Power with a 148 KB payload, measured in isolation and at steady state, p50 drops from 26.1 ms to 18.2 ms — about 8 ms per frame on a dense screen. Seedecisions/2026-09-02-single-pass-json-parser.md.Prop values keep the exact runtime types
org.jsonproduced —Int/Longfor integrals,Doublefor reals, realJSONObject/JSONArrayfor nested containers, andJSONObject.NULLfor a JSON null — becauseMobBridgereads them by type and a wrong type reads as null rather than failing loudly. The generated app ships 15 JVM unit tests for this, including a differential comparison against the old parser, and 40,000 fuzzed trees were checked against a vendored copy of the real AOSP implementation during review.
Added
:scrollcan compose only the rows on screen, withlazy: true. A vertical scroll whose sole child is a bare column (props limited tofill_width/fill_height, no child carryingweight) renders throughMobLazyListinstead ofColumn+verticalScroll. On a Moto G Power, toggling only this prop on a 500-row screen takes the main-thread frame cost from 498.9 ms to 115.8 ms (p50) and from 1385.9 ms to 164.8 ms (worst frame). Lazy cost is flat in list length where eager grows; short lists are unaffected or marginally slower, so this is a long-list optimisation.Opt-in on purpose. Rows below the fold are never composed, so they never register a frame —
Mob.Test.element_framesandtap_idcannot address them — and scroll position becomes index-based rather than pixel-based.lazy_listalready makes that trade explicitly; making it the silent default would change harness behaviour under apps that never asked for it. Seedecisions/2026-09-02-lazy-scroll-on-android.md.
Fixed
Children keep their identity across list edits. Children of every container were composed positionally, so inserting or removing a row made each following row adopt the previous occupant's state: typed text, scroll offset, focus and in-flight animations all shifted by one. Children now key on the author's
:idwhen there is one and on position when there is not, so nothing changes for code that never opted in. An authored id and a positional key live in separate namespaces, so an author whose id is literally"3"cannot collide with position 3, and a duplicate id falls back to position rather than merging two rows.Applies to all seven container sites — column, row, box, both scroll axes, the lazy list and the sheet body. Paired with mob 0.7.39+ for the iOS half, which derives keys the same way from the same rules.
The tab bar is not covered on Android. iOS keys it too; Compose's
NavigationBarstill iterates tabs positionally, so reordering or inserting a tab moves per-tab state on Android and not on iOS (MOB-127).The dead Android event handlers are wired into the bridge.
on_long_press,on_double_tap, the swipe family, the scroll family andon_dragwere accepted by the Elixir API and registered a handle, but the Compose bridge never called them, so they were silently inert on Android while working on iOS. Generated apps need this template change to receive them (MOB-138).Action required if you worked around this. A screen that declared one of these and never received it may have no matching
handle_infoclause; it will now start receiving messages it has never seen.on_focusandon_blurare NOT in this set — contrary to an earlier draft of this entry, they were already live in 0.4.30.Throttle config reaches native on Android. Scroll and drag throttle settings were computed on the BEAM side and never sent, so Android ignored them and delivered every raw event. The config is now sent per composition and applied to the handlers being built.
debounce,leadingandtrailingare accepted and stored but not yet acted on by either platform. Seedecisions/2026-09-03-android-throttle-config-per-composition.md(MOB-134).The element-frame registry is gated on the nav generation.
setRootJsoncleared the registry on navigation, but the write path had no gate, andAnimatedContentkeeps the outgoing composition mounted for its whole exit animation — so a screen sliding away kept firingonGloballyPositionedand refilled the registry with mid-animation coordinates.Mob.Test.element_framesandtap_idthen reported and tapped positions belonging to a screen the user was no longer looking at. A tracker now stamps every write with the generation current when it first composed, and stale writes are refused under the same lock that guards the clear. iOS has carried this since MOB-102/103.Two gaps stay open on Android and are NOT fixed here: there is no purge keyed on the live id set, so a same-screen re-render that drops an
:idleaves that element's last frame in the registry indefinitely; and there is noonDisposecompare-and-delete, so a lazy row scrolled out of range, an inactive tab or a dismissed sheet keeps its last position. Seedecisions/2026-09-03-android-frame-generation-gate.md(MOB-142).
[0.4.30] - 2026-08-31
Fixed
- Generated lazy-list scroll state survives event-handle generation changes.
List state is retained by the node's canonical
:idwhen one exists, otherwise by the stable event slot instead of the raw handle (which becomes generation-tagged in mob 0.7.38+), and negative unhandled sentinels map to no key rather than colliding with slot 255. Scroll position is preserved across re-renders with no per-frame growth of the retained-state map. With mob ≤ 0.7.37 this degrades to exactly the previous slot behavior; mob 0.7.38+ requires a template generated at 0.4.30+ or lazy lists reset scroll position on every re-render. Ships a generated instrumentation test. Companion to mob#114.
[0.4.29] - 2026-08-31
Fixed
- Android: a replacement sheet no longer inherits its predecessor's hidden
or already-dismissed state. The generated sheet renderer keys presentation
state (visibility + the exactly-once dismissal flag) by the sheet node's
id, canonicalized across every JSON id form (string, number, boolean, explicit null, array, object — key order irrelevant;"",0,false, andnullare all distinct from an absent id, which keeps the previous slot-scoped behavior). A replaced sheet's presentation is deactivated on disposal, so a late dismiss callback can neither hide the new sheet nor suppress its{:dismiss, tag}; re-renders that keep the sameidupdate content in place without re-presenting. Ships generated instrumentation coverage (SheetIdentityTest). A narrow dismiss-races-re-render window remains until mob's generation-tagged event handles land (mob#114, in revision) — this fix never widens it.
[0.4.28] - 2026-08-30
Fixed
- 0.4.27's published package could not compile. The compile-time Zig-pin
reader loaded the repository-root
.tool-versions, which Hex omits from packages, so a clean compile of the published artifact raisedFile.read!. The pin now ships inpriv/zig-version(included in the package), a lockstep test guards drift against the repo's.tool-versions, and a packed-archive regression builds and installs the archive from real unpacked Hex source. Use 0.4.28 instead of 0.4.27.
[0.4.27] - 2026-08-30
Fixed
- Generated projects pin the Android Zig toolchain.
.tool-versionsnow includeszig 0.17.0-dev.269+ebff43698(derived at compile time from mob_new's own pin), so a firstmix mob.deploy --nativeno longer fails toolchain detection on Android. LiveView-generated projects, which previously received no.tool-versions, now get one too; an existing.tool-versionsis never clobbered. The generated header notes that the zig dev pin requires mise (asdf cannot fetch historical nightlies). Companion to mob_dev 0.6.29's corrected install guidance.
[0.4.26] - 2026-08-29
Fixed
- Generated Android bridge now exposes
MobBridge.screenInfo(). Mob's optional JNI binding (nif_screen_info, mob 0.7+) expects a staticscreenInfo(): FloatArrayreturning[w, h, density, top, bottom, left, right]in dp; generated bridges omitted it, soMob.Test.screen_info/1returned all zeros on Android. UsescurrentWindowMetrics+getInsetsIgnoringVisibility(systemBars | displayCutout)on API 30+, with adecorView/rootWindowInsetsfallback below. Template lint rejects generated bridges that omit or misplace the entrypoint. Existing apps must regenerate or hand-port the bridge to pick this up.
[0.4.25] - 2026-08-27
Added
- Generated Android intrinsic Sheet support. The renderer parses typed
content detents from the JSON array, hugs short content, caps against the
configured
max_heightand the current parent constraint, and applies internal vertical scrolling to overflow.:medium/:largebehaviour is unchanged. - Generated Android composite Box accessibility.
accessibility_labelbecomescontentDescription, an explicit button role is preserved even with no tap handle,disabledsemantics are exposed, disabled interaction does not dispatch, and a passive labelled Box does not become a button.
Fixed
- A content Sheet containing a scrollable child crashed the app. The
content-detent path wrapped the sheet body in
verticalScrollunconditionally, and Compose throws when a scrollable is measured with an infinite max height — so ascrollorlazy_listinsidedetents: [:content], the most natural use of an intrinsic sheet, died at first measure withIllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints. The sheet now only adds its own scroll when the content has none; the height cap applies either way and the child keeps owning its scrolling. - A disabled Box dispatched taps unless it also carried an explicit button
role, while simultaneously reporting
disabled()semantics — announced as disabled to TalkBack and still firing. iOS disables every Box regardless of role, so this was also a platform split. - A Box declared
accessibility_role: :buttonwhose tap is handled by an ancestor was announced as "button, disabled", because Compose publishes disabled semantics wheneverclickable'senabledis false. - The Sheet height cap sat outside the node's own padding, so a padded sheet
overshot
max_height; iOS caps the already-padded body.
Changed
- Generated projects now require
{:mob, "~> 0.7.32"}(was"~> 0.7.31"). Intrinsic Sheet detents and composite Box accessibility only reach the generated Android renderer because mob 0.7.32 validates and encodes them; on an older mob those props never arrive and the behaviour silently degrades.
[0.4.24] - 2026-08-27
Added
- Android Sheet renderer —
Mob.UI.sheet/2now renders on Android as a Material 3ModalBottomSheet, matching the iOS.sheetprimitive that shipped in mob 0.7.29. Supports:detents(medium-only sheets included),:background,:scrim(applied exactly, unlike iOS where the system owns dimming opacity),:corner_radius, and the custom drag indicator props. Until nowMob.UI.sheet/2was an iOS-only primitive in practice: generated Android apps had no sheet case at all and rendered nothing.
Fixed
- Sheet dismissal delivers
{:dismiss, tag}, not{:tap, tag}. The renderer routed dismissal throughMobBridge.nativeSendTap, butMob.UI.sheet/2documents:on_dismissas{:dismiss, tag}and iOS delivers that. A screen written to the documented contract never matched:Mob.Screenforwards the unmatched message tohandle_info, raisingFunctionClauseErrorand killing the screen process — or a catch-all swallowed it, leaving the BEAM unaware the sheet had closed and unable to re-present it. Adds thenativeSendDismissextern onMobBridgeand the matchingbeam_jni.cthunk (MOB-104). detentswas never read from real BEAM data.detentsPropcastprops["detents"] as? List<*>, but JSON array props arrive asorg.json.JSONArray, which is not a KotlinList— so the cast always failed and every sheet silently fell back to["medium", "large"]. The medium-only detent could only ever trigger from a hand-builtMobNodein a test. Now branches onis JSONArray, the pattern already proven for:tabs.
Changed
- Generated projects now require
{:mob, "~> 0.7.31"}(was"~> 0.7"). The generated Androidbeam_jni.ccallsmob_send_dismiss, which mob only exports from 0.7.31. With the looser constraint an older mob resolved fine and then failed late inmix mob.deploy --android --nativewithcall to undeclared function 'mob_send_dismiss'.
Upgrading
- Existing Android projects need a hand-port.
MobBridge.ktandbeam_jni.care app-owned and never re-rendered, so regenerating is not enough.mix mob.doctor(mob_dev) warns when a project still carries the old wiring and prints the two-line change. If you worked around the old behaviour by matchinghandle_info({:tap, tag}, ...)for a sheet dismissal, that clause is now dead — switch it to{:dismiss, tag}.
[0.4.23] - 2026-08-26
Fixed
- Generated Android
MobNativeViewRegistry.render()didn't guard the MOB-100 exhaustion sentinel. mob 0.7.28 fixed native component handle pool exhaustion by returning a-1sentinel (instead of crashing) when the pool is full; iOS'sMobNativeViewRegistry.view(for:)was updated to skip rendering on-1, but the generated Android template'srender()still treated any non-nullcomponent_handleas valid, wiring up a native view against an unregistered handle. Now mirrors iOS:if (handle < 0) return.
[0.4.22] - 2026-08-26
Fixed
- Android JNI owner mismatch for tier-2 native components.
MobBridge.kt.eexdeclarednativeDeliverComponentEventas a bareexternal funonMobNativeViewRegistry, but the generated JNI export isJava_<pkg>_MobBridge_nativeDeliverComponentEvent— JNI resolves a native method by its declaring class, so a real event from a Compose native component threwUnsatisfiedLinkError. Moved the declaration ontoMobBridgeas@JvmStatic external fun, matching every othernativeDeliver*callback in the file. AddedMobNew.Templates.Lint.native_funs_owned_by_mob_bridge/1(wired into the standardcheck_kotlin/1lint pass) — the existingexternal_fun_jni_consistency/2only checked that Kotlin and C agreed on the function NAME, not which class actually owned the Kotlin declaration, so it stayed green through this exact bug; the new check is brace-depth aware, catching a native fun nested inside another class withinobject MobBridgetoo, not just one declared on an entirely different object. (MOB-98)
[0.4.21] - 2026-08-25
Added
- Generated
MobBridge.kt's font resolver now walks mob's font fallback chain, part of the mob custom-fonts feature (seemob'sMOB_FONTS.md).setThemeparses the_font_fallbackarray pushed byMob.Theme.set/1intoMobBridge.fontFallback;fontFamilyProp'sresolveOneFontNamewalks[the node's own font] + fontFallbackin order, trying each candidate (bundledres/font/resource first, then a raw system family name) until one resolves.
Fixed
- The fallback chain above never actually triggered a fallback.
Typeface.create(String, Int)never returns null and rarely throws for an unrecognized family name — per its own documentation it silently substitutesTypeface.DEFAULT.resolveOneFontName'stry/catcharound it never caught this, so the first fallback candidate always "succeeded" (as the system default) and the walk never reached the real fallback font. Found via physical-device verification (Android emulator running Android 15) immediately after this feature's own device check — row C of the test screen showed the plain system font instead of the expected fallback. Now detects failure via reference-equality againstTypeface.DEFAULTand logs + continues the walk instead. Re-verified on the same emulator: the fallback font renders correctly. (MOB-94)
[0.4.20] - 2026-07-07
Added
- Scaffolded
MobBridgeaudio-probe Kotlin methods backing mob 0.7.18'sMob.Audioprobes.audioOutputStatus()(volume / mute / route / other-audio viaAudioManager) andaudioOutputLevel(source)(peak/RMS dB from a short-livedVisualizeronMob.Audio's own player session, which works withRECORD_AUDIO; length-1 error codes the NIF maps to atoms). Plus micinput_levelmetering viaMediaRecorder.getMaxAmplitudefor the agent "ears" (MOB-35). Device-verified on a Moto G power (2021). (#25, #31)
[0.4.19] - 2026-07-04
Added
- Generated
MobBridgekeep-awake method forMob.Device.keep_awake/1(mob 0.7.17). Generated Android apps get aMobBridge.keepAwake(Int)that toggles the window'sFLAG_KEEP_SCREEN_ONon the UI thread — no permission. Adds theandroid.view.WindowManagerimport. Requires mob 0.7.17+. Device-verified on moto g power (2021). (MOB-20, #30)
[0.4.18] - 2026-07-04
Added
- Generated Android network-connectivity callback for
Mob.Device.network_state/0(mob 0.7.16).MainActivityregisters aConnectivityManager.NetworkCallbackthat mapsNetworkCapabilitiesto online / transport / expensive / validated and delivers the snapshot to the BEAM through abeam_jni.ctrampoline (nativeNotifyConnectivity→mob_send_connectivity_changed);onLostre-queries the active network rather than assuming offline. AddsACCESS_NETWORK_STATEto the manifest. Generator test covers the rendered extern / thunk / permission. Device-verified on moto g power (2021). (MOB-14, #28) mob_audio_capturepre-trusted in generated apps so the plugin activates without a manual trust prompt. (MOB-34, #29)
[0.4.17] - 2026-07-04
Added
- Generated
MobBridgetorch method forMob.Torch(mob 0.7.15). Generated Android apps get aMobBridge.torch(String)that toggles the rear-camera torch viaCameraManager.setTorchMode— no capture session, noCAMERApermission. It finds a camera with a flash unit, no-ops on flash-less devices, and swallows transient camera-access failures. Adds a not-requiredandroid.hardware.camera.flash<uses-feature>so flash-less devices still install. Requires mob 0.7.15+. Device-verified on moto g power (2021). (MOB-15, #27)
[0.4.16] - 2026-07-04
Added
- Generated
MobBridgeregisters the magnetometer / compass path forMob.Motion's:magnetometersensor (mob 0.7.14).motion_startparses the requested sensor set out of its spec arg and, only when:magnetometerwas asked for, registersTYPE_MAGNETIC_FIELD+TYPE_ROTATION_VECTORand routes through the newnativeDeliverMotionMagJNI thunk (with the matchingbeam_jni.cbinding tomob_deliver_motion_mag). Accel/gyro-only apps keep the byte-identical 3-key path. Requires mob 0.7.14+. (MOB-6, #26)
Added
MobBridge.openSettings/1scaffolding forMob.Device.open_settings/1(mob 0.7.8). Generated Android apps can now open an OS settings screen by target::app(app details / permissions),:notifications, or:exact_alarm(with the correct SDK 31+ guard and a fallback to the app page). iOS needs no companion (the NIF opens the single app settings page).
[0.4.14] - 2026-06-25
Added
- Device-orientation lock works out of the box. Generated apps now ship the
companion native hooks that
Mob.Device.lock_orientation/1/unlock_orientation/0need to actually hold a rotation (mob 0.7.7+):- iOS:
AppDelegatereturnsmob_locked_orientation_mask()fromapplication:supportedInterfaceOrientationsForWindow:(the window-level override that holds the lock over the root view controller), andInfo.plistdeclares the landscape + upside-downUISupportedInterfaceOrientations. - Android:
MobBridge.orientationLock/1→setRequestedOrientation, andMainActivity.onConfigurationChangedforwards rotations to the BEAM via anativeNotifyOrientation→mob_send_orientation_changedJNI thunk, soMob.Device.orientation/0and the:displaysubscription track rotations.
- iOS:
[0.4.13] - 2026-06-24
Added
- The generated demo's theme picker now offers Material 3 and Liquid Glass
alongside Light and Dark (a 2×2 grid). "Material 3" applies
MobThemes.Material3(M3 baseline palette + shape scale); "Liquid Glass" appliesMobThemes.ObsidianGlass, which renders realglassEffecttranslucent surfaces on iOS 26+ (.ultraThinMaterialfallback on iOS 17–25). Both themes already shipped in themob_themesdependency; this just wires them into the picker. The new tabs are gated behind the showcase (non---blank) build, so a--blankapp, which doesn't depend onmob_themes, stays clean and keeps just Light/Dark.
Fixed
Tapping a local notification now brings the generated Android app to the foreground. The template's
NotificationReceiver.onReceivebuilt and posted the notification but never set a content intent, so the tap was a no-op in every scaffolded app. It now attaches aPendingIntentthat relaunchesMainActivity(singleTop) carrying the payload undermob_notification_json— the exact keyMainActivity.onCreate/onNewIntentalready read to forward it to the BEAM. AProjectGeneratorTestcase asserts the renderedMobBridge.ktwiressetContentIntent+PendingIntent.getActivity+ the payload key, so the template can't silently regress. Verified end-to-end on a physical device. (#23)Known limitation (not fixed here): for local notifications, a warm tap forwards the payload on the next screen mount rather than to the live screen, since
MobBridge.notifyPidis only set by the push-registration path. The tap reliably foregrounds the app; live in-app delivery on a warm tap needs separate core/host plumbing.
[0.4.11] - 2026-06-19
Changed (default scaffolding)
- Generated Android apps no longer ship a foreground service or Firebase by
default. Removed the
dataSyncBeamForegroundService, the FCMMobFirebaseService, thegoogle-servicesplugin/classpath + firebase dependency, the placeholdergoogle-services.json, the FGS permissions, and theMobBridgebackground-keep-alive methods from the template. Both drew Google Play policy scrutiny (unuseddataSyncFGS /c2dmpermissions) on apps that used neither. They are now opt-in: background keep-alive via themob_backgroundplugin, FCM via themob_notifyplugin (whosegradle_depauto-merges; the host service +google-servicesare documentedhost_requirements).POST_NOTIFICATIONSstays (local notifications).
[0.4.10] - 2026-06-19
Added
build.ziglinks plugin:cpp_archivestatic libs via-Dplugin_static_libsacross the android JNI, iOS sim, and iOS device templates — pairs with mob_dev 0.6.10's cpp_archive plugin path. (#22)mix mob.newscaffolds aMob.ScreenCasetest for the generated home screen (test/<app>/home_screen_test.exs), pairs with mob 0.7.2. (#21)
[0.4.9] - 2026-06-17
Fixed
- Generated Android apps now support 16 KB memory page sizes (Google Play
requirement on Android 15+). The android
build.ziglinkslib<app>.soandlibsqlite3_nif.sowith-Wl,-z,max-page-size=16384, so their LOAD segments align to 16 KB. Without it, Play rejects the AAB with "app does not support 16 KB memory page sizes". The prebuilt ERTSlib*.sowere already 16 KB-aligned by the OTP NDK build; only the two zig-linked app libs needed the flag. Pinned by a generator test. (Apps generated before 0.4.9 need the flag added to their app-ownedandroid/app/src/main/jni/build.zig.)
[0.4.5] - 2026-06-16
Added
- Android x86_64 emulator slice in generated apps (resolves GenericJam/mob#20).
build.gradleabiFilters gainsx86_64+OTP_RELEASE_X86_64;CMakeLists.txtandbuild.zighandle the x86_64 ABI. Pairs with mob_dev 0.6.4's x86_64 OTP runtime, so generated apps run on x86_64 emulators (Intel / CI hosts).
[0.4.4] - 2026-06-16
Added
- Generated
mob.exsshipsconfig :mob, :trusted_pluginswith the shared mob first-party signing-key fingerprint for all official plugins. The bundled showcase plugins (now signed on Hex) clear the signature gate out of the box — noacknowledge_unsafe_pluginsneeded — and any other first-party plugin a user activates is pre-trusted.
[0.4.3] - 2026-06-15
Fixed
- Android
build.zig: wireerts/jniimports for plugin zig NIFs. The plugin-zig-NIF compile path passed.mob_dirtoaddZigObject, but theZigObjectOptionsstruct had no such field and the helper never wired the@import("erts")/@import("jni")named modules a plugin NIF needs — so any app activating a zig-NIF plugin (camera/location/biometric all ship one) failed to build for Android. Added the field + module wiring (mob_erts.zig / mob_zig.zig, std-only + PIC). Found + fixed verifying the showcase on a physical Android phone.
[0.4.2] - 2026-06-15
Added
- Showcase plugins by default. Generated apps now depend on
mob_camera,mob_location,mob_biometric, andmob_themes, activate them inmob.exs(config :mob, :plugins/:styles/:default_style), and the home screen enumeratesMob.Plugins.screens/0to auto-list each plugin's demo screen — so a fresh app demonstrates real device capabilities out of the box (the mob analogue ofmix phx.newshipping Ecto). Remove any plugin frommix.exs+mob.exsto drop it; the home list and native build adjust with no other edits.
Fixed
--local(path-dep) generation now marks the:mobpath depoverride: true, so a local mob checkout satisfies themob ~> 0.7requirement the Hex showcase plugins declare (Mix won't otherwise use a path dep for a Hex sub-requirement).
[0.4.1] - 2026-06-12
Fixed
- The sample
CameraScreentemplate survived the 0.4.0 capability strip while its native half didn't — generated apps warned on the removedMob.Cameracore API and would crash on that screen. Removed the screen and its home nav entry (caught by the published-archive smoke test).
[0.4.0] - 2026-06-12
Changed
- Generated apps target mob 0.7 / mob_dev 0.6 (the plugin-extraction majors).
- Templates stripped of the extracted capabilities — camera capture/frame-stream Kotlin, camera/scanner/notify code, media-read + notification permissions, the scanner
<activity>, and the preset-theme switcher (now Light/Dark baseline; presets ship in themob_themesstyle package). The plugins re-supply each via their manifests on activation. - iOS plugin objc NIFs build with Apple clang (
addObjcObject); Androidbuild.ziggains theplugin_zig_nifs/plugin_jni_sourcesoptions. - Notification delivery state rides the generated
io.mob.plugin.MobNotifyHub(host code and themob_notifyplugin share it without cross-package references).
Fixed
- Dead dotfile templates deleted (byte-diff-proven);
.credo.exsnow actually ships in archive-generated apps (it was silently dropped by the archive's dotfile-excluding wildcard); the inline.tool-versionspins Elixir 1.20.0 final (was rc.5 — the on-device stdlib-skew version).
[0.3.16]
Fixed
- Generated Android apps are now shippable to Play out of the box. The
android/app/build.gradletemplate was missing three things every release build needs, so a freshly generated app crashed or got rejected at the Play Store (all three hit while shipping a real app):useLegacyPackaging true(newpackagingOptions { jniLibs { … } }block). AGP defaults release App Bundles toextractNativeLibs=false, leaving native libs packed in the APK — but the BEAMdlopenslib<app>.soby absolute path and execsinet_gethost/epmdas real processes, so they must be on the filesystem. Without this the app crashes on launch on a Play-installed split (dlopen … library not found); debug builds default totrue, which masked it.- Release
signingConfigsreadingandroid/keystore.properties(generated bymix mob.setup.google_play), wired intobuildTypes.release— otherwise the release AAB is unsigned and Play rejects it. compileSdk/targetSdk34 → 35 — Play requires API 35 for new apps and updates.
[0.3.15]
Added
- Test-harness driving in
MobBridge.kt.eex(Kotlin side of mob #40; companion to mob 0.6.23). Generated apps gain the bridge methods mob's Zig NIFs call so a remotely-connected agent can capture, scroll, and locate elements over Erlang dist with noadb:screenshot(format, quality, scale)(PixelCopy→ PNG/JPEG bytes, decor-viewdrawfallback pre-API-26); an id-keyedScrollHandleregistry withscrollInfo(id)/scrollTo(id, x, y)(pixelforScrollState/verticalScroll,indexforLazyListState); andframeTrackingModifier(id)(testTag+onGloballyPositioned) feedingelementFrames()→ JSON{id:[x,y,w,h]}in dp. Opt-in per:id; untagged nodes get no tracking modifier. Registries clear on navigation. Verified end-to-end on a moto g power (Android 11) via a generated app. - Android WebView handles HTML file inputs.
MobWebViewwiresonShowFileChooser, so<input type="file">in an embedded page now opens the system picker instead of silently doing nothing.
Fixed
- Bridge scroll/element-frame registries are now thread-safe.
scrollHandlesByIdandelementFramesByIdare written from the Compose main thread (registration /onGloballyPositioned) and read from the NIF/binder thread (scrollInfo/scrollTo/elementFrames). They were plainmutableMapOf, so a concurrent layout during a read could throwConcurrentModificationException. Both are nowConcurrentHashMap(weakly-consistent iteration, no CME), andscrollHandle/1usescomputeIfAbsentso a registration race can't drop a handle. (iOS already guarded its registry with@synchronized.)
[0.3.13]
Fixed
- Generated
.credo.exsnow actually runs ex_slop. The template registered{ExSlop, []}underchecks.enabled, but ex_slop ≥ 0.4.2 is a Credo plugin — listed as a check it's ignored as an "undefined check" and runs zero ex_slop checks, silently (the build still passes, so the regression is invisible). Generated projects have had AI-slop linting disabled. Now registered underplugins:, with the dep pinned to~> 0.4.2(the plugin-API version) so config and version can't drift. A generator test pins it. (The mob/mob_dev/mob_new repos themselves are unaffected — they're on ex_slop 0.4.0, where ExSlop is still a check and the existing wiring is correct.)
[0.3.12]
Fixed
- Generated
.gitignorenow excludes native build artifacts and signing secrets. The template only ignored_build/deps/*.beam/android/app/build/, so a fresh project that ran a native build and thengit add -Awould commit.cxx/and.zig-cache/outputs, the bundled OTP zip (~19 MB), compiled*.o/*.so/*.a, and — worst — the Android signing keystore +keystore.properties. Both generation paths are covered: the bare-Mob.gitignoreheredoc, andpatch_gitignore/1on the LiveView path now appends the native-excludes block to Phoenix's own.gitignore(idempotent, sentinel-guarded). A generator test pins the critical patterns. Surfaced when pushing a fresh project to GitHub and finding build junk staged.
[0.3.11]
Fixed
- Android WebView now fills its bounds —
MobWebViewsetsMATCH_PARENTlayout params plususeWideViewPort/loadWithOverviewMode. The WebView previously defaulted towrap_content, so any full-viewport page loaded into it (CSS100vh/100%— e.g. an embedded xterm.js terminal) measured its container as 0px and rendered blank. Surfaced while building a terminal-in-mob proof-of-concept; fix verified on a physical Android device (xterm.js terminal renders and accepts input). iOS WKWebView is unaffected — it's sized by its SwiftUI frame, sovhunits already resolve.
[0.3.10]
Added
MobBridge.kt.eexshipsaudio_play_atand supporting WAV chunk walker. Pairs withMob.Audio.play_at/4in mob 0.6.17 — sample-accurate scheduled playback against the Android audio hardware clock viaAudioTrackinMODE_STREAM. CoarseThread.sleep+ 3 ms busy-wait for sub-buffer-tick wakeup precision,THREAD_PRIORITY_AUDIOto favour scheduling, 64 KB chunked feed fromRandomAccessFileso multi-MB stems don't load fully into memory. Per-device output-latency calibration viaAudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER/PROPERTY_OUTPUT_SAMPLE_RATE, cached after first probe.- WAV header parser handles intermediate
LIST/INFOchunks betweenfmtanddata— ffmpeg-generated WAVs often emit metadata chunks the naive "header is at byte 44" approach would skip.
Fixed
audio_stop_playbackno longer bails early when no legacyMediaPlayeris active. Previously theaudioPlayer ?: returnshort-circuit prevented the scheduled-track cleanup from running, so apps usingaudio_play_atexclusively had a Stop call that silently no-op'd on the audio path while the UI thought it had stopped.
[0.3.9]
Fixed
- Android
build.zig.eextemplate now exposestflite_staticas ab.optionand threads it into thedriver_tab_androidbuild options alongsidenx_eigen_static. Without this, any newly-scaffolded project that adds a static NIF and runsmix mob.regen_driver_tabwould fail to compile witherror: root source file struct 'options' has no member named 'tflite_static'— the driver_tab generator unconditionally referencesbuild_options.tflite_static, but the template only declarednx_eigen_static. Asymmetry surfaced while wiring up a fresh rustler-using test app; the fix restores symmetry between the two guarded NIFs that mob_dev's StaticNifs defaults declare.
[0.3.8]
Added
project_swift_sourcesbuild hook on iOS templates. Bothios/build.zig.eexandios/build_device.zig.eexnow accept-Dproject_swift_sources=<absolute,paths>— a comma-separated list of extra Swift sources to compile into the sameswiftcinvocation as Mob's bridge sources. Empty/unset is a no-op. Paired withmob_dev's:project_swift_sourcesmob.exs key (mob_dev#6) so downstream apps can ship project Swift without patching the generator. Originally proposed by @dl-alexandre.MobBridge.kt.eex:MobTextFieldnow honourssecure: true. AppliesPasswordVisualTransformation()to mask input and overrides the keyboard type toKeyboardType.Password(autocorrect off, no suggestions strip). Mirrors the iOS-sidesecureprop landing in mob 0.6.x. The Elixir cleartext still reaches the BEAM viaon_changeso apps hash/store the value as normal.Existing apps generated from prior templates are unaffected — the prop is a no-op there. Regenerating or hand-porting
MobBridge.ktenables masking.
[0.3.7]
Added
- MaterialTheme follows BEAM-side
Mob.Themeout of the box. Apps generated frommix mob.newnow wire Compose's MaterialTheme to the BEAM-pushed theme so Material 3 system widgets (NavigationBar, Button, …) match whateverMob.Theme.set(...)the host has active — no more white-on-light NavigationBar over a dark Obsidian / ObsidianGlass page. Two pieces:MobBridge.kt:setTheme(json)JNI handler called bymob 0.6.14's new:mob_nif.set_theme/1. Decodes the resolved palette JSON into amutableStateOf<Map<String, Long>?>(Compose-observable; cross-thread-safe via a main-looper hop).MainActivity.kt: readsMobBridge.themeColorsand buildsdarkColorScheme(...)from it. Mob'ssurface_raised/mutedmap onto Material 3'ssurfaceVariant/onSurfaceVariant(same role). StockdarkColorScheme()covers the brief gap betweensetContentand the BEAM's first theme push.
- Requires
mob ≥ 0.6.14. Older runtimes don't define thesetThemeBridge method or call the NIF, so MaterialTheme just stays on the fallback — no breakage, but the system widgets won't followMob.Theme.set/1.
[0.3.6]
Added
Mob.Camera.start_frame_stream/2Android support baked into the template. Apps generated frommix mob.newnow ship the Kotlin side (camera_start_frame_stream,camera_stop_frame_stream,deliverFrame,centerCropAndScale,bitmapToRgbF32,bitmapToBgraU8) plus the JNI thunk (nativeDeliverCameraFrame) wired through tomob_deliver_camera_frame. Prior to this, projects generated frommob_new≤ 0.3.5 would fail at NIF load againstmob ≥ 0.6.8— the new JNI bindings forcamera_start_frame_stream/camera_stop_frame_streamarecacheRequired, so missing static methods caused the app to crash on launch.aspect_ratiomodifier prop innodeModifier(Android) — wrapsModifier.aspectRatio(r). Useful for locking camera + canvas overlays to a 1:1 square so model-space coordinates align with the visible preview area.
Fixed
MobCameraPreviewZ-order and stability fixes so Compose overlays (status text, bounding boxes, etc.) drawn on top of the camera actually render:- Switched
PreviewViewfrom defaultImplementationMode.PERFORMANCE(SurfaceView, punches through above Compose and hides overlays) toCOMPATIBLE(TextureView, renders inside the normal Compose Z-order). - Moved the camera bind out of
AndroidView.update(which re-runs on every recomposition and caused continualunbindAll/bindToLifecyclecycles whenever any sibling state ticked — e.g. an FPS counter — making the surface flicker and fight with overlays) into aLaunchedEffect(frameActive, cameraSelector)keyed only on values that should trigger a rebind. - Added
Modifier.clipToBounds()to theAndroidViewwrapping thePreviewViewso the surface texture can't bleed past its declared layout bounds. PreviewView.scaleType = FILL_CENTERto match the model-side center-crop inMobBridge.deliverFrame, so overlay-canvas coords align with the preview underneath.
- Switched
[0.3.5]
Fixed
beam_jni.c.eex: restored the closing}fornativeDeliverVendorUsbEvent. Without it, clang sees nested function definitions and rejects everyJNIEXPORTthat follows ("function definition is not allowed here") — C compilation fails immediately on any project generated from 0.3.3/0.3.4.MobBridge.kt.eex: removed three duplicate imports (IntentFilter,ConcurrentHashMap,AtomicInteger). Each now appears exactly once in the alphabetised bottom-of-file import block. kotlinc was rejecting with "Conflicting import" sogradleDebugfailed on any project generated from 0.3.3/0.3.4.beam_jni.c.eex: removed a 276-line duplicate Bluetooth Classic JNI block (lines 533-808 mirrored 256-531 verbatim). Caused "redefinition ofJava_..._MobBridge_nativeDeliverBt*" errors at compile time. The canonical first block stays. (Surfaced by the newclang -fsyntax-onlytest below — string-match generator-test assertions still passed because the substrings exist, just twice.)
All three regressions were introduced by the 0.3.3 BT-PR merge taking the union of imports / blocks during conflict resolution, when the 0.3.2 fix had specifically de-duplicated them. Reported by external user on master 2026-05-17.
Added
MobNew.Templates.Lint— module of structural lints for generator-rendered native source files. 8 checks:balanced_braces,balanced_parens,balanced_brackets,no_eex_leaks,unique_kotlin_imports,unique_swift_imports,external_fun_jni_consistency, pluscheck_kotlin/1/check_c/1/check_swift/1aggregate functions. Returns a list of issue maps; empty list = clean. 25 unit tests cover each check red-then-green.- Generator tests now use
Lint.check_kotlin/1andLint.check_c/1instead of inline brace-counting + import-dedup logic. Single source of truth; clearer failure messages. - New cross-file consistency test: asserts every
external fun nativeFoo(...)in MobBridge.kt has a matchingJava_..._MobBridge_nativeFoo(...)JNI thunk in beam_jni.c. Catches "added the Kotlin side but forgot the C thunk" (or the reverse). - New
clang -fsyntax-onlytest (@tag :requires_android_ndk) that invokes the NDK's clang against the renderedbeam_jni.c. Catches the full class of "actually broken C" — typos, wrong arg counts, duplicate definitions — that tier-1 structural lints can miss. Skips cleanly when the Android NDK isn't installed (mirrors the existing:requires_zigpattern).
[0.3.4]
Added
CLAUDE.md"Release flow" section pointing at the canonical process inmob/RELEASE.md(URL form so it resolves without a local mob checkout). mob_new specifics: generator tests needMOB_DIR=/Users/kevin/code/mobset when running from a worktree (the resolver looks formobalongside the project and the worktree path breaks that assumption)..githooks/pre-push— same script shipped in mob (cheap preflight always, release preflight whenmix.exschanged). Activate per clone or worktree withgit config core.hooksPath .githooks.
[0.3.3]
Added
- Bluetooth Classic peripheral codegen (
MobBridge.kt.eex,beam_jni.c.eex,AndroidManifest.xml.eex) — generated apps now include the Kotlin BroadcastReceivers, JNI native_* externs, and Android permissions for theMob.Btruntime API (HFP / SPP / HID). Companion tomob0.6.5'sMob.Btmodule. Contributed by @HeroesLament (#4).
[0.3.2]
Fixed
- HexDocs
source_urlandsource_url_patternpointed at the wrong repo (mobinstead ofmob_new) and at a non-existent/mob_new/subdirectory prefix; the rendered</>glyphs all 404'd. Corrected togithub.com/genericjam/mob_new/blob/master/.... - Template fix:
beam_jni.c.eexwas missing the closing}fornativeDeliverVendorUsbEventbefore the BT JNI thunks began — every subsequentJNIEXPORT void JNICALLwas rejected by clang with "function definition is not allowed here". Generator tests never caught this because they grep rendered output, not compile it. - Template fix:
MobBridge.kt.eexduplicated three imports (IntentFilter,ConcurrentHashMap,AtomicInteger) alongside the BT Bluetooth* imports; kotlinc rejected with "Conflicting import". - Template fix:
MobBridge.kt.eexmissingandroidx.compose.foundation.layout.fillMaxSizeimport for the GpuView compile-error overlay. - Template fix: orphan comment in the import block confused ktlint's
import-orderingrule (no autocorrect available when imports are interleaved with comments).
Added
.github/workflows/test.yml— runsmix test,mix format --check-formatted,mix credo --strict, andmix deps.auditon push to master and on every PR..github/workflows/release.yml— on tag push, creates a GitHub Release whose body is the matching## [X.Y.Z]section from this changelog.
[0.3.1]
Added
- Bluetooth Classic template scaffolding:
MobBridge.kt.eexgains the Kotlin BroadcastReceivers,external fun nativeDeliver*JNI declarations, and Compose wiring for theMob.Btruntime API (HFP / SPP / HID).AndroidManifest.xml.eexgains the matching modern + legacy Bluetooth permissions.beam_jni.c.eexgains the per-event JNI thunks. (Generator tests cover the rendered template's external strings; manual on-device verification per the CLAUDE.md convention.) Mob.GpuViewAndroid backend (GLES 3.0):MobBridge.kt.eexgainsMobGpuViewcomposable +MobGpuSurfaceView+MobGpuRenderer, mirroring the iOSMobGpuView.swiftshipped in mob 0.6.4. Same%{ios: "...MSL...", android: "...GLSL ES..."}cross-platform shader contract; std140-ish uniform packing matches the iOS Swift packer (scalar/vec2/vec4 with natural alignment). Translucent red compile-error overlay on shader failure, matching iOS behavior.- Generator-test coverage for both surfaces — asserts the rendered template contains the expected composables, classes, imports, and dispatch entries.
[0.3.0] and earlier
Earlier releases predate this changelog; consult the tag list and the per-tag commit messages for history.