| @@ -0,0 +1,240 @@ |
| 1 | +//! The Android actions that need nothing but the JVM: `getMemoryUsage` and |
| 2 | +//! `log`. |
| 3 | +//! |
| 4 | +//! Tier 0 in the sense the iOS migration used: no permission prompt, no UI, no |
| 5 | +//! callback, and — the part specific to Android — **no `Activity`**. Both reach |
| 6 | +//! a static class and stop, so neither depends on anything the app hands over. |
| 7 | +//! |
| 8 | +//! ## `getMemoryUsage` is not the iOS action wearing a different name |
| 9 | +//! |
| 10 | +//! iOS asks the kernel for this process's resident size through `task_info`. |
| 11 | +//! Android asks the *JVM* about its own heap through `java.lang.Runtime`, and |
| 12 | +//! those are different quantities: `Runtime.totalMemory()` is what the managed |
| 13 | +//! heap has reserved, not what the process holds. Every native allocation this |
| 14 | +//! bridge makes is outside it. |
| 15 | +//! |
| 16 | +//! So the two platforms answer with different keys and that is correct rather |
| 17 | +//! than sloppy — iOS emits `usedMB`/`residentSize`/`virtualSize`, Android emits |
| 18 | +//! `usedMB`/`maxMB`/`totalMB`/`usedBytes`/`maxBytes`. Only `usedMB` is shared, |
| 19 | +//! and even that means something different. Making them agree would mean one |
| 20 | +//! of them lying about which number it measured. |
| 21 | +//! |
| 22 | +//! ## The rounding is arithmetic, not formatting |
| 23 | +//! |
| 24 | +//! `Math.round(usedMemory / 1024.0 / 1024.0 * 100) / 100.0` is hundredths of a |
| 25 | +//! megabyte, rounded half up. Done here in integers for the reason |
| 26 | +//! `bridge_mobile_device.zig` gives for the same figure on iOS: the rounding |
| 27 | +//! becomes a property of a function that can be asserted on a host, rather than |
| 28 | +//! of a float format that has to be observed. |
| 29 | + |
| 30 | +const std = ; |
| 31 | +const jni = ; |
| 32 | + |
| 33 | +const Jni = jni.Jni; |
| 34 | + |
| 35 | +/// The action names, spelled exactly as `CraftBridge.kt` spells them. The |
| 36 | +/// conformance ratchet matches the two lists by string. |
| 37 | +pub const A = struct { |
| 38 | + pub const get_memory_usage = "getMemoryUsage"; |
| 39 | + pub const log = "log"; |
| 40 | +}; |
| 41 | + |
| 42 | +/// What the JVM reports about its own heap. |
| 43 | +pub const MemoryUsage = struct { |
| 44 | + used_bytes: i64, |
| 45 | + max_bytes: i64, |
| 46 | + total_bytes: i64, |
| 47 | +}; |
| 48 | + |
| 49 | +/// Bytes as hundredths of a megabyte, rounded half up. |
| 50 | +/// |
| 51 | +/// Integer arithmetic so the rounding is assertable. `Math.round` in Java is |
| 52 | +/// `floor(x + 0.5)`, which for a non-negative byte count is the same as adding |
| 53 | +/// half the divisor before dividing — and a byte count is never negative, so |
| 54 | +/// the difference between round-half-up and round-half-away never arises. |
| 55 | +/// |
| 56 | +/// The divisor is 1024*1024/100 scaled: hundredths of a MiB means |
| 57 | +/// `bytes * 100 / (1024*1024)`, and the `+ half` is the round. |
| 58 | +pub fn hundredthsOfMebibyte(bytes: i64) i64 { |
| 59 | + if (bytes <= 0) return 0; |
| 60 | + const mib: i64 = 1024 * 1024; |
| 61 | + return @divTrunc(bytes * 100 + @divTrunc(mib, 2), mib); |
| 62 | +} |
| 63 | + |
| 64 | +/// `usedMB` and friends, rendered the way `JSONObject.put(double)` renders |
| 65 | +/// them: a whole number loses its fraction, everything else keeps exactly the |
| 66 | +/// hundredths it has. |
| 67 | +/// |
| 68 | +/// Java prints `117.0` for a whole double and Zig's `{d}` prints `117`. That |
| 69 | +/// difference is invisible to `JSON.parse` — both produce the same number — and |
| 70 | +/// chasing it would mean formatting by hand to match a Java quirk no consumer |
| 71 | +/// can observe. |
| 72 | +fn appendMegabytes(allocator: std.mem.Allocator, out: *std.ArrayListUnmanaged(u8), bytes: i64) !void { |
| 73 | + const hundredths = hundredthsOfMebibyte(bytes); |
| 74 | + const fraction = @mod(hundredths, 100); |
| 75 | + |
| 76 | + try out.print(allocator, "{d}.", .{@divTrunc(hundredths, 100)}); |
| 77 | + // Padded by hand rather than with a width spec. `{d:0>2}` renders `+74` |
| 78 | + // on this toolchain — a sign, not a zero — which is not a number JSON |
| 79 | + // accepts, and the reply stops parsing at the dot. |
| 80 | + if (fraction < 10) try out.append(allocator, '0'); |
| 81 | + try out.print(allocator, "{d}", .{fraction}); |
| 82 | +} |
| 83 | + |
| 84 | +/// The reply bytes for `getMemoryUsage`. |
| 85 | +/// |
| 86 | +/// Key order follows the Kotlin's `put` order, for the same reason the device |
| 87 | +/// module follows it: a diff against the Kotlin is how this gets reviewed. |
| 88 | +pub fn renderMemory(allocator: std.mem.Allocator, usage: MemoryUsage) ![]u8 { |
| 89 | + var out: std.ArrayListUnmanaged(u8) = .empty; |
| 90 | + errdefer out.deinit(allocator); |
| 91 | + |
| 92 | + try out.appendSlice(allocator, "{\"usedMB\":"); |
| 93 | + try appendMegabytes(allocator, &out, usage.used_bytes); |
| 94 | + try out.appendSlice(allocator, ",\"maxMB\":"); |
| 95 | + try appendMegabytes(allocator, &out, usage.max_bytes); |
| 96 | + try out.appendSlice(allocator, ",\"totalMB\":"); |
| 97 | + try appendMegabytes(allocator, &out, usage.total_bytes); |
| 98 | + |
| 99 | + // The byte counts are exact and stay integers. A `long` past 2^53 would |
| 100 | + // lose precision in `JSON.parse`, but a JVM heap that large does not exist. |
| 101 | + try out.appendSlice(allocator, ",\"usedBytes\":"); |
| 102 | + try out.print(allocator, "{d}", .{usage.used_bytes}); |
| 103 | + try out.appendSlice(allocator, ",\"maxBytes\":"); |
| 104 | + try out.print(allocator, "{d}", .{usage.max_bytes}); |
| 105 | + try out.append(allocator, '}'); |
| 106 | + |
| 107 | + return out.toOwnedSlice(allocator); |
| 108 | +} |
| 109 | + |
| 110 | +/// Ask `java.lang.Runtime` about the managed heap. |
| 111 | +/// |
| 112 | +/// `used = total - free` is the Kotlin's own arithmetic, and it is the only |
| 113 | +/// one of the three that is derived rather than read. |
| 114 | +pub fn readMemory(j: Jni) !MemoryUsage { |
| 115 | + try j.pushLocalFrame(8); |
| 116 | + defer _ = j.popLocalFrame(null); |
| 117 | + |
| 118 | + const cls = try j.findClass("java/lang/Runtime"); |
| 119 | + const runtime = try j.callStaticObjectMethodA( |
| 120 | + cls, |
| 121 | + try j.staticMethodId(cls, "getRuntime", "()Ljava/lang/Runtime;"), |
| 122 | + &.{}, |
| 123 | + ); |
| 124 | + |
| 125 | + const total = try j.callLongMethod(runtime, try j.methodId(cls, "totalMemory", "()J")); |
| 126 | + const free = try j.callLongMethod(runtime, try j.methodId(cls, "freeMemory", "()J")); |
| 127 | + const max = try j.callLongMethod(runtime, try j.methodId(cls, "maxMemory", "()J")); |
| 128 | + |
| 129 | + return .{ .used_bytes = total - free, .max_bytes = max, .total_bytes = total }; |
| 130 | +} |
| 131 | + |
| 132 | +/// `android.util.Log.d("CraftBridge", message)`. |
| 133 | +/// |
| 134 | +/// The tag is the Kotlin's, character for character. Anyone filtering logcat on |
| 135 | +/// `CraftBridge` is filtering on a string, and changing it because Zig now |
| 136 | +/// emits the line would break a filter that has nothing to do with this |
| 137 | +/// migration. |
| 138 | +pub fn writeLog(j: Jni, message: [*:0]const u8) !void { |
| 139 | + try j.pushLocalFrame(8); |
| 140 | + defer _ = j.popLocalFrame(null); |
| 141 | + |
| 142 | + const cls = try j.findClass("android/util/Log"); |
| 143 | + const tag = try j.newStringUtf("CraftBridge"); |
| 144 | + const text = try j.newStringUtf(message); |
| 145 | + |
| 146 | + // `Log.d` returns the number of bytes written, which the Kotlin ignores |
| 147 | + // and so does this. Discarding it is not the same as calling a void |
| 148 | + // method: the JVM would push a different frame for `CallStaticVoidMethodA`. |
| 149 | + _ = try j.callStaticIntMethodA( |
| 150 | + cls, |
| 151 | + try j.staticMethodId(cls, "d", "(Ljava/lang/String;Ljava/lang/String;)I"), |
| 152 | + &.{ .{ .l = tag }, .{ .l = text } }, |
| 153 | + ); |
| 154 | +} |
| 155 | + |
| 156 | +// ============================================================================= |
| 157 | +// Tests |
| 158 | +// ============================================================================= |
| 159 | + |
| 160 | +const testing = std.testing; |
| 161 | + |
| 162 | +test "hundredths of a mebibyte round the way Math.round does" { |
| 163 | + // Exact boundaries first: one MiB is 100 hundredths by definition. |
| 164 | + try testing.expectEqual(@as(i64, 100), hundredthsOfMebibyte(1024 * 1024)); |
| 165 | + try testing.expectEqual(@as(i64, 200), hundredthsOfMebibyte(2 * 1024 * 1024)); |
| 166 | + try testing.expectEqual(@as(i64, 0), hundredthsOfMebibyte(0)); |
| 167 | + |
| 168 | + // Half rounds up, which is what `floor(x + 0.5)` does for a positive x. |
| 169 | + // Half a hundredth of a MiB is 1048576/200 = 5242.88 bytes, so 5243 is the |
| 170 | + // first byte count at or above the halfway point and 5242 is below it. |
| 171 | + // (Writing the boundary as `1024*1024/200` truncates to 5242 and asserts |
| 172 | + // the opposite of what it looks like — which is what the first version of |
| 173 | + // this test did.) |
| 174 | + try testing.expectEqual(@as(i64, 1), hundredthsOfMebibyte(5243)); |
| 175 | + try testing.expectEqual(@as(i64, 0), hundredthsOfMebibyte(5242)); |
| 176 | + |
| 177 | + // A negative cannot come from a byte count, and answering 0 is better than |
| 178 | + // answering a negative megabyte. |
| 179 | + try testing.expectEqual(@as(i64, 0), hundredthsOfMebibyte(-1)); |
| 180 | +} |
| 181 | + |
| 182 | +test "the memory reply carries every key the Kotlin puts, and parses" { |
| 183 | + const usage = MemoryUsage{ |
| 184 | + .used_bytes = 123_456_789, |
| 185 | + .max_bytes = 536_870_912, |
| 186 | + .total_bytes = 268_435_456, |
| 187 | + }; |
| 188 | + const json = try renderMemory(testing.allocator, usage); |
| 189 | + defer testing.allocator.free(json); |
| 190 | + |
| 191 | + var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); |
| 192 | + defer parsed.deinit(); |
| 193 | + const obj = parsed.value.object; |
| 194 | + |
| 195 | + try testing.expectEqual(@as(usize, 5), obj.count()); |
| 196 | + try testing.expectEqual(@as(i64, 123_456_789), obj.get("usedBytes").?.integer); |
| 197 | + try testing.expectEqual(@as(i64, 536_870_912), obj.get("maxBytes").?.integer); |
| 198 | + |
| 199 | + // 123456789 / 1048576 = 117.7375… → 117.74 |
| 200 | + try testing.expectApproxEqAbs(@as(f64, 117.74), obj.get("usedMB").?.float, 0.0001); |
| 201 | + // 536870912 is exactly 512 MiB, and 268435456 exactly 256. |
| 202 | + try testing.expectApproxEqAbs(@as(f64, 512), obj.get("maxMB").?.float, 0.0001); |
| 203 | + try testing.expectApproxEqAbs(@as(f64, 256), obj.get("totalMB").?.float, 0.0001); |
| 204 | +} |
| 205 | + |
| 206 | +test "a whole number of megabytes still renders as a number" { |
| 207 | + // The `.00` path: `512.00` is valid JSON and parses to 512, but a bug in |
| 208 | + // the fraction split would produce `512.` or `512.0` — one of which is not |
| 209 | + // JSON at all. |
| 210 | + const json = try renderMemory(testing.allocator, .{ |
| 211 | + .used_bytes = 1024 * 1024, |
| 212 | + .max_bytes = 1024 * 1024, |
| 213 | + .total_bytes = 1024 * 1024, |
| 214 | + }); |
| 215 | + defer testing.allocator.free(json); |
| 216 | + |
| 217 | + try testing.expect(std.mem.indexOf(u8, json, "\"usedMB\":1.00,") != null); |
| 218 | + |
| 219 | + var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); |
| 220 | + defer parsed.deinit(); |
| 221 | + try testing.expectApproxEqAbs(@as(f64, 1), parsed.value.object.get("usedMB").?.float, 0.0001); |
| 222 | +} |
| 223 | + |
| 224 | +test "a heap under a hundredth of a megabyte does not render as garbage" { |
| 225 | + // The zero-fraction edge, from the other side: a two-digit pad is what |
| 226 | + // stops 5 hundredths becoming "0.5". |
| 227 | + const json = try renderMemory(testing.allocator, .{ |
| 228 | + .used_bytes = 52_429, // ~0.05 MiB |
| 229 | + .max_bytes = 0, |
| 230 | + .total_bytes = 0, |
| 231 | + }); |
| 232 | + defer testing.allocator.free(json); |
| 233 | + try testing.expect(std.mem.indexOf(u8, json, "\"usedMB\":0.05,") != null); |
| 234 | + try testing.expect(std.mem.indexOf(u8, json, "\"maxMB\":0.00,") != null); |
| 235 | +} |
| 236 | + |
| 237 | +test "the action names match the Kotlin methods exactly" { |
| 238 | + try testing.expectEqualStrings("getMemoryUsage", A.get_memory_usage); |
| 239 | + try testing.expectEqualStrings("log", A.log); |
| 240 | +} |