Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob).
mob_new, which move with it.

### Fixed
- **Android Zig sources compile on Zig 0.17.0.** `std.fmt.bufPrintZ` was
removed, so the post-mortem marker paths in `mob_nif.zig` use
`std.mem.printSentinel`, and `Allocator.dupeZ` was removed, so
`mob_stored_queue.zig`'s tests use `dupeSentinel`. CI now sets up the
pinned Zig from `.tool-versions`, compiles `mob_nif.zig` for Android and
runs every Android Zig test file.
- **Android Zig sources compile on Zig 0.17.0, and still on the old
nightly.** `std.fmt.bufPrintZ` was removed in 0.17.0, and its replacement
`std.mem.printSentinel` is missing in `0.17.0-dev.269`, so the post-mortem
marker paths in `mob_nif.zig` format through a local `printZ` on
`std.fmt.bufPrint`, which both have. An app that updates mob but keeps
mob_dev 0.7.30 (which pins the nightly) still builds for Android.
`Allocator.dupeZ` was removed, so `mob_stored_queue.zig`'s tests use
`dupeSentinel`. CI now sets up the pinned Zig from `.tool-versions`,
compiles `mob_nif.zig` for Android and runs every Android Zig test file.
- **iOS sheets take their identity from `:id` (#115).** A sheet's
presentation state was tied to its place in the tree, and a container's
child keying (MOB-127) does not reach every sheet, such as one that is the
Expand Down
20 changes: 16 additions & 4 deletions android/jni/mob_nif.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4915,14 +4915,26 @@ fn filesDirPath(env: *jni.JNIEnv, context: jni.JObject) ?[]const u8 {

const MARKER_FILENAME = "mob_post_mortem_appexit_marker.txt";

// Formats into `buf` and NUL-terminates. Built on std.fmt.bufPrint because it
// exists in both Zig toolchains apps may build with: the 0.17.0 stable mob_dev
// pins now and the 0.17.0-dev.269 nightly older mob_dev pins. The sentinel
// helpers differ between them (bufPrintZ is gone in 0.17.0, printSentinel is
// missing in the nightly), and an app can resolve this mob with either.
fn printZ(buf: []u8, comptime fmt: []const u8, args: anytype) ?[:0]u8 {
if (buf.len == 0) return null;
const s = std.fmt.bufPrint(buf[0 .. buf.len - 1], fmt, args) catch return null;
buf[s.len] = 0;
return buf[0..s.len :0];
}

// Read the persisted last-seen timestamp. Returns 0 on first-ever
// sweep (no marker file), on parse failure, or on any I/O error —
// treating any of those as "we have not seen anything yet" is the
// safe direction: at worst we re-emit some historic capsules the
// Registry then dedups within the current BEAM session.
fn readMarker(dir: []const u8) i64 {
var path_buf: [640]u8 = @splat(0);
const path = std.mem.printSentinel(&path_buf, "{s}/{s}", .{ dir, MARKER_FILENAME }, 0) catch return 0;
const path = printZ(&path_buf, "{s}/{s}", .{ dir, MARKER_FILENAME }) orelse return 0;

const f = jni.fopen(path.ptr, "rb") orelse return 0;
defer _ = jni.fclose(f);
Expand Down Expand Up @@ -4952,7 +4964,7 @@ fn readMarker(dir: []const u8) i64 {
// console on a permanently-failing path.
fn writeMarker(dir: []const u8, ts_ms: i64) void {
var path_buf: [640]u8 = @splat(0);
const path = std.mem.printSentinel(&path_buf, "{s}/{s}", .{ dir, MARKER_FILENAME }, 0) catch {
const path = printZ(&path_buf, "{s}/{s}", .{ dir, MARKER_FILENAME }) orelse {
logMarkerFailureOnce(.path_too_long);
return;
};
Expand All @@ -4965,7 +4977,7 @@ fn writeMarker(dir: []const u8, ts_ms: i64) void {
defer _ = jni.close(fd);

var out_buf: [32]u8 = @splat(0);
const out = std.mem.printSentinel(&out_buf, "{d}\n", .{ts_ms}, 0) catch {
const out = printZ(&out_buf, "{d}\n", .{ts_ms}) orelse {
logMarkerFailureOnce(.format_failed);
return;
};
Expand Down Expand Up @@ -5001,7 +5013,7 @@ fn logMarkerFailureOnce(kind: MarkerFailure) void {
};

var buf: [128]u8 = @splat(0);
const msg = std.mem.printSentinel(&buf, "mob_post_mortem: marker write failed ({s}); cross-boot dedup will re-emit until this clears", .{reason}, 0) catch return;
const msg = printZ(&buf, "mob_post_mortem: marker write failed ({s}); cross-boot dedup will re-emit until this clears", .{reason}) orelse return;
jni.logWrite(jni.ANDROID_LOG_ERROR, "MobNIF", "{s}", .{msg});
}

Expand Down
9 changes: 7 additions & 2 deletions lib/mob/keyboard.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,16 @@ defmodule Mob.Keyboard do
`handle_info` / `handle_event` return. Nothing focused is not an error.
"""
@spec dismiss(Mob.Socket.t()) :: Mob.Socket.t()
def dismiss(socket) do
def dismiss(socket), do: __dismiss__(socket, :mob_nif)

@doc false
# The NIF module is a parameter so a test can stand in for :mob_nif.
@spec __dismiss__(Mob.Socket.t(), module()) :: Mob.Socket.t()
def __dismiss__(socket, nif) do
# Guarded, unlike Mob.Haptic: this sits in handlers Mob.ScreenCase drives
# on the host, where no NIF library loads and the call raises — a screen
# that chains it has to stay testable there.
if function_exported?(:mob_nif, :dismiss_keyboard, 0), do: :mob_nif.dismiss_keyboard()
if function_exported?(nif, :dismiss_keyboard, 0), do: nif.dismiss_keyboard()
socket
end
end
18 changes: 18 additions & 0 deletions test/mob/keyboard_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,22 @@ defmodule Mob.KeyboardTest do
socket = Mob.Socket.new(Screen, platform: :no_render)
assert Mob.Keyboard.dismiss(socket) == socket
end

defmodule FakeNif do
@moduledoc false
# Stands in for :mob_nif: reports the call to the test that set itself up.
def dismiss_keyboard do
send(:persistent_term.get({__MODULE__, :test}), :dismiss_keyboard)
:ok
end
end

test "dismiss takes the keyboard down through the NIF and returns the socket" do
:persistent_term.put({FakeNif, :test}, self())
on_exit(fn -> :persistent_term.erase({FakeNif, :test}) end)
socket = Mob.Socket.new(Screen, platform: :no_render)

assert Mob.Keyboard.__dismiss__(socket, FakeNif) == socket
assert_received :dismiss_keyboard
end
end
9 changes: 9 additions & 0 deletions test/mob/native_event_handle_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,15 @@ defmodule Mob.NativeEventHandleTest do
assert ios_dismiss =~ "mob_send_identity_event(handle, \"dismiss\")"
end

# mob#173: Mob.Keyboard.dismiss/1 clears focus in the same handler that may
# re-render, so the blur lands after the new generation committed. Strict
# resolution dropped 6 of 8 of them on a Moto G.
test "Android blur resolves by identity, so a re-render does not drop it" do
[_, android_blur] = String.split(@android_source, "pub export fn mob_send_blur(", parts: 2)
[android_blur, _] = String.split(android_blur, "}", parts: 2)
assert android_blur =~ "sendIdentityEvent(handle, \"blur\")"
end

test "plain taps stay strict; press-node taps and press pairs survive a re-render" do
# A plain tap resolves generation-strictly: a stale positional tag such as
# Mob.List's {:select, id, index} must be dropped, not routed to whatever
Expand Down
Loading