| @@ -1,25 +1,28 @@ |
| 1 | | -//! Two of the `mobile` namespace's three HealthKit actions: |
| 2 | | -//! `requestHealthAuthorization` and `getHealthData`. |
| 1 | +//! All three of the `mobile` namespace's HealthKit actions: |
| 2 | +//! `requestHealthAuthorization`, `getHealthData` and `saveHealthWorkout`. |
| 3 | 3 | //! |
| 4 | | -//! ## Why two and not three, when the last two pairs moved whole |
| 4 | +//! ## What the split cost, and why it is closed |
| 5 | 5 | //! |
| 6 | | -//! `saveHealthWorkout` stays with the Swift shim this round, and the reason is |
| 7 | | -//! the opposite of the one that kept `openPDF`/`closePDF` and the audio pair |
| 8 | | -//! together. Those shared *module state*: a controller one stored and the other |
| 9 | | -//! dismissed, a recorder one built and the other stopped. Splitting them left |
| 10 | | -//! half a state machine in each language. |
| 6 | +//! `saveHealthWorkout` was deferred one round, and the reason was cost rather |
| 7 | +//! than correctness |
| 11 | 8 | //! |
| 12 | | -//! Nothing flows between these three. The only thing they share is an |
| 13 | | -//! `HKHealthStore`, which is a stateless handle — authorization lives in the |
| 14 | | -//! system database, not in the object, and Apple supports more than one store |
| 15 | | -//! per process. So Zig owning two of them and Swift the third produces no |
| 16 | | -//! divided state and no wrong answer, only a smaller diff. |
| 9 | +//! — unlike `openPDF`/`closePDF` and the audio pair, which shared *module |
| 10 | +//! state* and had to move whole. The only thing these three share is an |
| 11 | +//! `HKHealthStore`, a stateless handle whose authorization lives in the system |
| 12 | +//! database, so a split was never at risk of dividing state. |
| 17 | 13 | //! |
| 18 | | -//! What `saveHealthWorkout` costs is a third asynchronous stage: |
| 19 | | -//! `HKWorkout` construction, then `save:withCompletion:`, then an |
| 20 | | -//! `HKWorkoutRouteBuilder` chain for the optional `locations` array — each with |
| 21 | | -//! its own completion, each able to fail after the previous succeeded. That is |
| 22 | | -//! a round of its own, not a tail on this one. |
| 14 | +//! The cost was a third asynchronous stage: `save:withCompletion:`, then |
| 15 | +//! `insertRouteData:completion:`, then |
| 16 | +//! `finishRouteWithWorkout:metadata:completion:` — each able to fail after the |
| 17 | +//! previous succeeded, and none able to carry what the next needs, since all |
| 18 | +//! three blocks are global and capture nothing. `WorkoutChain` is what rides |
| 19 | +//! the slot between them. |
| 20 | +//! |
| 21 | +//! Two places diverge from Swift, both in the same direction. Swift *rejects* |
| 22 | +//! when a route fails to build, insert or finish — after the workout has |
| 23 | +//! already been written to HealthKit. That tells a page nothing was saved when |
| 24 | +//! something was, and leaves it unable to reference a workout that exists. Here |
| 25 | +//! a route failure still resolves the workout's id, with the reason logged. |
| 23 | 26 | //! |
| 24 | 27 | //! ## The store is created here, not inherited |
| 25 | 28 | //! |
| @@ -55,22 +58,21 @@ const is_darwin = builtin.target.os.tag.isDarwin(); |
| 55 | 58 | pub const A = struct { |
| 56 | 59 | pub const request_health_authorization = "requestHealthAuthorization"; |
| 57 | 60 | pub const get_health_data = "getHealthData"; |
| 61 | + pub const save_health_workout = "saveHealthWorkout"; |
| 58 | 62 | }; |
| 59 | 63 | |
| 60 | 64 | pub const capability_actions = [_]capabilities.ActionDecl{ |
| 61 | 65 | .{ .name = A.request_health_authorization, .reply = .result }, |
| 62 | 66 | .{ .name = A.get_health_data, .reply = .result }, |
| 67 | + .{ .name = A.save_health_workout, .reply = .result }, |
| 63 | 68 | }; |
| 64 | 69 | |
| 65 | | -/// The action this module was scoped to and, on purpose, does not serve. |
| 70 | +/// Nothing in this family is unserved any more. |
| 66 | 71 | /// |
| 67 | | -/// Recorded as data rather than prose so a test can hold both properties the |
| 68 | | -/// decision rests on: it is not declared, and it still falls out of |
| 69 | | -/// `handleMessage` as `UnknownAction` — the one return `ios_dispatch.route` |
| 70 | | -/// turns into a Swift-shim hand-off. |
| 71 | | -pub const deliberately_unserved = [_][]const u8{ |
| 72 | | - "saveHealthWorkout", |
| 73 | | -}; |
| 72 | +/// `saveHealthWorkout` was the last entry, deferred one round for its third |
| 73 | +/// asynchronous stage. The empty list is kept rather than deleted so the test |
| 74 | +/// that walks it keeps compiling and the next deferral has somewhere to go. |
| 75 | +pub const deliberately_unserved = [_][]const u8{}; |
| 74 | 76 | |
| 75 | 77 | /// `requestHealthAuthorization`'s reply on success: the bare JSON `true`. |
| 76 | 78 | const authorized_reply = "true"; |
| @@ -140,9 +142,9 @@ pub const HealthBridge = struct { |
| 140 | 142 | try self.requestAuthorization(data); |
| 141 | 143 | } else if (std.mem.eql(u8, action, A.get_health_data)) { |
| 142 | 144 | try self.getHealthData(data); |
| 145 | + } else if (std.mem.eql(u8, action, A.save_health_workout)) { |
| 146 | + try self.saveWorkout(data); |
| 143 | 147 | } else { |
| 144 | | - // `saveHealthWorkout` lands here on purpose. See the module |
| 145 | | - // comment and `deliberately_unserved`. |
| 146 | 148 | return BridgeError.UnknownAction; |
| 147 | 149 | } |
| 148 | 150 | } |
| @@ -181,6 +183,63 @@ pub const HealthBridge = struct { |
| 181 | 183 | request(store, sel, share, read, @ptrCast(&auth_blocks[ticket.index])); |
| 182 | 184 | } |
| 183 | 185 | |
| 186 | + /// Save a workout, then optionally attach a GPS route to it. |
| 187 | + /// |
| 188 | + /// Three asynchronous stages, each able to fail after the previous |
| 189 | + /// succeeded: `save:withCompletion:`, then — only when the page sent |
| 190 | + /// locations — `insertRouteData:completion:` and |
| 191 | + /// `finishRouteWithWorkout:metadata:completion:`. Everything the later |
| 192 | + /// stages need is parked on the slot at dispatch, because none of the |
| 193 | + /// three blocks can carry it. |
| 194 | + /// |
| 195 | + /// A workout with no usable locations resolves after stage one, matching |
| 196 | + /// Swift's early `guard !locations.isEmpty`. That is not an error: the |
| 197 | + /// workout really was saved, and a route is optional. |
| 198 | + fn saveWorkout(self: *Self, data: []const u8) !void { |
| 199 | + if (!is_darwin) return BridgeError.PlatformNotSupported; |
| 200 | + |
| 201 | + const store = try healthStore(); |
| 202 | + |
| 203 | + var parsed = std.json.parseFromSlice(std.json.Value, self.allocator, data, .{}) catch |err| switch (err) { |
| 204 | + error.OutOfMemory => return err, |
| 205 | + else => return BridgeError.InvalidJSON, |
| 206 | + }; |
| 207 | + defer parsed.deinit(); |
| 208 | + |
| 209 | + const request = try readWorkoutRequest(parsed.value); |
| 210 | + |
| 211 | + const workout = try buildWorkout(self.allocator, request); |
| 212 | + errdefer releaseObject(workout); |
| 213 | + |
| 214 | + const locations = try buildLocations(self.allocator, parsed.value, request); |
| 215 | + |
| 216 | + const activity_id = std.heap.c_allocator.dupe(u8, request.activity_id) catch |
| 217 | + return BridgeError.AllocationFailed; |
| 218 | + errdefer std.heap.c_allocator.free(activity_id); |
| 219 | + |
| 220 | + const ticket = ios_async.acquire(A.save_health_workout) orelse { |
| 221 | + std.log.warn( |
| 222 | + "saveHealthWorkout: no free reply slot; {d} native calls are already awaiting one", |
| 223 | + .{ios_async.max_in_flight}, |
| 224 | + ); |
| 225 | + return BridgeError.NativeCallFailed; |
| 226 | + }; |
| 227 | + errdefer ios_async.abandon(ticket); |
| 228 | + |
| 229 | + publishWorkoutCall(ticket, .{ |
| 230 | + .workout = workout, |
| 231 | + .activity_id = activity_id, |
| 232 | + .locations = locations, |
| 233 | + .store = store, |
| 234 | + }); |
| 235 | + |
| 236 | + const sel = objc.sel_registerName("saveObject:withCompletion:") orelse |
| 237 | + return BridgeError.NativeCallFailed; |
| 238 | + const SaveFn = *const fn (Id, objc.SEL, Id, *anyopaque) callconv(.c) void; |
| 239 | + const save: SaveFn = @ptrCast(&objc.objc_msgSend); |
| 240 | + save(store, sel, workout, @ptrCast(&save_blocks[ticket.index])); |
| 241 | + } |
| 242 | + |
| 184 | 243 | /// Sum one quantity type over a window and answer `{value, unit}`. |
| 185 | 244 | fn getHealthData(self: *Self, data: []const u8) !void { |
| 186 | 245 | if (!is_darwin) return BridgeError.PlatformNotSupported; |
| @@ -509,6 +568,299 @@ fn optionalMilliseconds(object: std.json.ObjectMap, key: []const u8) !?f64 { |
| 509 | 568 | }; |
| 510 | 569 | } |
| 511 | 570 | |
| 571 | +// =========================================================================== |
| 572 | +// saveHealthWorkout |
| 573 | +// =========================================================================== |
| 574 | + |
| 575 | +/// `HKWorkoutActivityType`, read from `HKWorkout.h`'s enum rather than |
| 576 | +/// guessed. |
| 577 | +/// |
| 578 | +/// The enum numbers most members implicitly, so the values are only obtainable |
| 579 | +/// by counting from the last explicit one. Every value here was wrong on |
| 580 | +/// instinct — running reads as 37 if you assume it is near walking — and a |
| 581 | +/// wrong number saves a real workout under the wrong activity, which HealthKit |
| 582 | +/// accepts without complaint. Same lesson as |
| 583 | +/// `statistics_option_cumulative_sum` above, applied before the mistake |
| 584 | +/// instead of after it. |
| 585 | +const WorkoutActivity = struct { |
| 586 | + name: []const u8, |
| 587 | + value: c_ulong, |
| 588 | +}; |
| 589 | + |
| 590 | +const workout_activities = [_]WorkoutActivity{ |
| 591 | + .{ .name = "running", .value = 34 }, |
| 592 | + .{ .name = "walking", .value = 49 }, |
| 593 | + .{ .name = "hiking", .value = 22 }, |
| 594 | + .{ .name = "cycling", .value = 13 }, |
| 595 | +}; |
| 596 | + |
| 597 | +fn workoutActivityFor(name: []const u8) ?c_ulong { |
| 598 | + for (workout_activities) |entry| { |
| 599 | + if (std.mem.eql(u8, entry.name, name)) return entry.value; |
| 600 | + } |
| 601 | + return null; |
| 602 | +} |
| 603 | + |
| 604 | +/// `CLLocationCoordinate2D` — two doubles, passed by value. |
| 605 | +const CLLocationCoordinate2D = extern struct { |
| 606 | + latitude: f64, |
| 607 | + longitude: f64, |
| 608 | +}; |
| 609 | + |
| 610 | +/// Swift's defaults for the two optional per-location fields. |
| 611 | +const default_altitude: f64 = 0; |
| 612 | +const default_horizontal_accuracy: f64 = 10; |
| 613 | +/// Swift passes -1, which is CoreLocation's "unknown". |
| 614 | +const unknown_vertical_accuracy: f64 = -1; |
| 615 | + |
| 616 | +const WorkoutRequest = struct { |
| 617 | + activity_id: []const u8, |
| 618 | + activity: c_ulong, |
| 619 | + /// Seconds since 1970. |
| 620 | + start: f64, |
| 621 | + end: f64, |
| 622 | + distance_meters: ?f64, |
| 623 | + energy_calories: ?f64, |
| 624 | +}; |
| 625 | + |
| 626 | +/// Swift's four-clause guard, plus `endValue > startValue`. |
| 627 | +/// |
| 628 | +/// Every clause rejects with one message there, so the codes here are the |
| 629 | +/// distinction the page could not previously make: a missing field is |
| 630 | +/// `MissingData`, a mistyped or out-of-order one is `InvalidParameter`. |
| 631 | +fn readWorkoutRequest(payload: std.json.Value) !WorkoutRequest { |
| 632 | + const object = switch (payload) { |
| 633 | + .object => |o| o, |
| 634 | + else => return BridgeError.InvalidJSON, |
| 635 | + }; |
| 636 | + |
| 637 | + const activity_id = try requiredString(object, "activityId"); |
| 638 | + const type_name = try requiredString(object, "type"); |
| 639 | + const activity = workoutActivityFor(type_name) orelse { |
| 640 | + std.log.warn("saveHealthWorkout refused: unsupported workout type '{s}'", .{type_name}); |
| 641 | + return BridgeError.InvalidParameter; |
| 642 | + }; |
| 643 | + |
| 644 | + const start = (try optionalMilliseconds(object, "startDate")) orelse return BridgeError.MissingData; |
| 645 | + const end = (try optionalMilliseconds(object, "endDate")) orelse return BridgeError.MissingData; |
| 646 | + if (!(end > start)) { |
| 647 | + std.log.warn("saveHealthWorkout refused: endDate is not after startDate", .{}); |
| 648 | + return BridgeError.InvalidParameter; |
| 649 | + } |
| 650 | + |
| 651 | + return .{ |
| 652 | + .activity_id = activity_id, |
| 653 | + .activity = activity, |
| 654 | + .start = start, |
| 655 | + .end = end, |
| 656 | + // Swift's `.flatMap { $0 > 0 ? … : nil }`: a non-positive value is |
| 657 | + // dropped rather than saved as zero, because a workout with an |
| 658 | + // explicit zero distance reads as "measured none" where nil reads as |
| 659 | + // "did not measure". |
| 660 | + .distance_meters = try positiveNumber(object, "distanceMeters"), |
| 661 | + .energy_calories = try positiveNumber(object, "activeEnergyCalories"), |
| 662 | + }; |
| 663 | +} |
| 664 | + |
| 665 | +fn requiredString(object: std.json.ObjectMap, key: []const u8) ![]const u8 { |
| 666 | + const value = object.get(key) orelse return BridgeError.MissingData; |
| 667 | + const text = switch (value) { |
| 668 | + .string => |t| t, |
| 669 | + else => return BridgeError.InvalidParameter, |
| 670 | + }; |
| 671 | + if (text.len == 0) return BridgeError.InvalidParameter; |
| 672 | + return text; |
| 673 | +} |
| 674 | + |
| 675 | +fn positiveNumber(object: std.json.ObjectMap, key: []const u8) !?f64 { |
| 676 | + const value = object.get(key) orelse return null; |
| 677 | + const number: f64 = switch (value) { |
| 678 | + .float => |f| f, |
| 679 | + .integer => |i| @floatFromInt(i), |
| 680 | + .null => return null, |
| 681 | + else => return BridgeError.InvalidParameter, |
| 682 | + }; |
| 683 | + return if (number > 0) number else null; |
| 684 | +} |
| 685 | + |
| 686 | +/// `+[HKWorkout workoutWithActivityType:startDate:endDate:workoutEvents:totalEnergyBurned:totalDistance:metadata:]`, |
| 687 | +/// retained. |
| 688 | +fn buildWorkout(allocator: std.mem.Allocator, request: WorkoutRequest) !Id { |
| 689 | + const HKWorkout = objc.objc_getClass("HKWorkout") orelse return BridgeError.PlatformNotSupported; |
| 690 | + const sel = objc.sel_registerName( |
| 691 | + "workoutWithActivityType:startDate:endDate:workoutEvents:totalEnergyBurned:totalDistance:metadata:", |
| 692 | + ) orelse return BridgeError.NativeCallFailed; |
| 693 | + |
| 694 | + const start_date = try dateFromInterval(request.start); |
| 695 | + const end_date = try dateFromInterval(request.end); |
| 696 | + |
| 697 | + const energy = if (request.energy_calories) |c| |
| 698 | + try quantityWith("kcal", c) |
| 699 | + else |
| 700 | + null; |
| 701 | + const distance = if (request.distance_meters) |m| |
| 702 | + try quantityWith("m", m) |
| 703 | + else |
| 704 | + null; |
| 705 | + |
| 706 | + const metadata = try workoutMetadata(allocator, request.activity_id); |
| 707 | + |
| 708 | + const WorkoutFn = *const fn (objc.Class, objc.SEL, c_ulong, Id, Id, Id, Id, Id, Id) callconv(.c) Id; |
| 709 | + const workoutWith: WorkoutFn = @ptrCast(&objc.objc_msgSend); |
| 710 | + const workout = workoutWith( |
| 711 | + HKWorkout, |
| 712 | + sel, |
| 713 | + request.activity, |
| 714 | + start_date, |
| 715 | + end_date, |
| 716 | + null, |
| 717 | + energy, |
| 718 | + distance, |
| 719 | + metadata, |
| 720 | + ) orelse return BridgeError.NativeCallFailed; |
| 721 | + |
| 722 | + // Autoreleased by the class method; retained because it has to survive |
| 723 | + // two more asynchronous stages. |
| 724 | + return retainObject(workout); |
| 725 | +} |
| 726 | + |
| 727 | +fn quantityWith(unit: [*:0]const u8, value: f64) !Id { |
| 728 | + const HKQuantity = objc.objc_getClass("HKQuantity") orelse return BridgeError.PlatformNotSupported; |
| 729 | + const sel = objc.sel_registerName("quantityWithUnit:doubleValue:") orelse |
| 730 | + return BridgeError.NativeCallFailed; |
| 731 | + const resolved_unit = try unitFromString(unit); |
| 732 | + const QuantityFn = *const fn (objc.Class, objc.SEL, Id, f64) callconv(.c) Id; |
| 733 | + const quantityWithFn: QuantityFn = @ptrCast(&objc.objc_msgSend); |
| 734 | + return quantityWithFn(HKQuantity, sel, resolved_unit, value) orelse BridgeError.NativeCallFailed; |
| 735 | +} |
| 736 | + |
| 737 | +/// `@{HKMetadataKeyExternalUUID: activityId, HKMetadataKeyIndoorWorkout: @NO}`. |
| 738 | +fn workoutMetadata(allocator: std.mem.Allocator, activity_id: []const u8) !Id { |
| 739 | + const NSMutableDictionary = objc.objc_getClass("NSMutableDictionary") orelse |
| 740 | + return BridgeError.NativeCallFailed; |
| 741 | + const metadata = objc.allocInit(NSMutableDictionary) catch return BridgeError.NativeCallFailed; |
| 742 | + |
| 743 | + const sel_set = objc.sel_registerName("setObject:forKey:") orelse |
| 744 | + return BridgeError.NativeCallFailed; |
| 745 | + |
| 746 | + const external_key = try healthConstant("HKMetadataKeyExternalUUID"); |
| 747 | + const ns_activity = objc.createNSString(activity_id, allocator) catch |
| 748 | + return BridgeError.AllocationFailed; |
| 749 | + objc.msgSendVoid2(metadata, sel_set, ns_activity, external_key); |
| 750 | + |
| 751 | + const indoor_key = try healthConstant("HKMetadataKeyIndoorWorkout"); |
| 752 | + const NSNumber = objc.objc_getClass("NSNumber") orelse return BridgeError.NativeCallFailed; |
| 753 | + const sel_bool = objc.sel_registerName("numberWithBool:") orelse |
| 754 | + return BridgeError.NativeCallFailed; |
| 755 | + const BoolFn = *const fn (objc.Class, objc.SEL, bool) callconv(.c) Id; |
| 756 | + const numberWith: BoolFn = @ptrCast(&objc.objc_msgSend); |
| 757 | + const no = numberWith(NSNumber, sel_bool, false) orelse return BridgeError.NativeCallFailed; |
| 758 | + objc.msgSendVoid2(metadata, sel_set, no, indoor_key); |
| 759 | + |
| 760 | + return metadata; |
| 761 | +} |
| 762 | + |
| 763 | +/// The `locations` array as an `NSArray<CLLocation *>`, or null when there is |
| 764 | +/// nothing usable in it. |
| 765 | +/// |
| 766 | +/// Null and empty are the same thing to the caller — both take Swift's early |
| 767 | +/// `guard !locations.isEmpty` exit — so a page that sends ten malformed |
| 768 | +/// locations gets its workout saved with no route rather than an error. That |
| 769 | +/// is Swift's `compactMap`, which drops what it cannot read. |
| 770 | +fn buildLocations( |
| 771 | + allocator: std.mem.Allocator, |
| 772 | + payload: std.json.Value, |
| 773 | + request: WorkoutRequest, |
| 774 | +) !Id { |
| 775 | + const object = switch (payload) { |
| 776 | + .object => |o| o, |
| 777 | + else => return null, |
| 778 | + }; |
| 779 | + const field = object.get("locations") orelse return null; |
| 780 | + const items = switch (field) { |
| 781 | + .array => |a| a, |
| 782 | + else => return null, |
| 783 | + }; |
| 784 | + |
| 785 | + const CLLocation = objc.objc_getClass("CLLocation") orelse return null; |
| 786 | + const sel_alloc = objc.sel_registerName("alloc") orelse return null; |
| 787 | + const sel_init = objc.sel_registerName( |
| 788 | + "initWithCoordinate:altitude:horizontalAccuracy:verticalAccuracy:timestamp:", |
| 789 | + ) orelse return null; |
| 790 | + const InitFn = *const fn (Id, objc.SEL, CLLocationCoordinate2D, f64, f64, f64, Id) callconv(.c) Id; |
| 791 | + const initFn: InitFn = @ptrCast(&objc.objc_msgSend); |
| 792 | + |
| 793 | + var built: std.ArrayListUnmanaged(Id) = .empty; |
| 794 | + defer built.deinit(allocator); |
| 795 | + |
| 796 | + for (items.items) |item| { |
| 797 | + const fields = switch (item) { |
| 798 | + .object => |o| o, |
| 799 | + else => continue, |
| 800 | + }; |
| 801 | + const latitude = numberField(fields, "latitude") orelse continue; |
| 802 | + const longitude = numberField(fields, "longitude") orelse continue; |
| 803 | + const timestamp_ms = numberField(fields, "timestamp") orelse continue; |
| 804 | + const seconds = timestamp_ms / 1000.0; |
| 805 | + |
| 806 | + // Swift filters the built list to the workout's window; doing it |
| 807 | + // before construction is the same set without the allocations. |
| 808 | + if (seconds < request.start or seconds > request.end) continue; |
| 809 | + |
| 810 | + const allocated = objc.msgSendId(CLLocation, sel_alloc) orelse continue; |
| 811 | + const location = initFn( |
| 812 | + allocated, |
| 813 | + sel_init, |
| 814 | + .{ .latitude = latitude, .longitude = longitude }, |
| 815 | + numberField(fields, "altitude") orelse default_altitude, |
| 816 | + numberField(fields, "accuracy") orelse default_horizontal_accuracy, |
| 817 | + unknown_vertical_accuracy, |
| 818 | + try dateFromInterval(seconds), |
| 819 | + ) orelse continue; |
| 820 | + |
| 821 | + try built.append(allocator, location); |
| 822 | + } |
| 823 | + |
| 824 | + if (built.items.len == 0) return null; |
| 825 | + |
| 826 | + const NSArray = objc.objc_getClass("NSArray") orelse return null; |
| 827 | + const sel_array = objc.sel_registerName("arrayWithObjects:count:") orelse return null; |
| 828 | + const ArrayFn = *const fn (objc.Class, objc.SEL, [*]const Id, c_ulong) callconv(.c) Id; |
| 829 | + const arrayWith: ArrayFn = @ptrCast(&objc.objc_msgSend); |
| 830 | + const array = arrayWith(NSArray, sel_array, built.items.ptr, built.items.len) orelse return null; |
| 831 | + return retainObject(array); |
| 832 | +} |
| 833 | + |
| 834 | +fn numberField(object: std.json.ObjectMap, key: []const u8) ?f64 { |
| 835 | + const value = object.get(key) orelse return null; |
| 836 | + return switch (value) { |
| 837 | + .float => |f| f, |
| 838 | + .integer => |i| @floatFromInt(i), |
| 839 | + else => null, |
| 840 | + }; |
| 841 | +} |
| 842 | + |
| 843 | +fn uuidStringOf(object: Id) ?[]const u8 { |
| 844 | + const sel_uuid = objc.sel_registerName("UUID") orelse return null; |
| 845 | + const uuid = objc.msgSendId(object, sel_uuid) orelse return null; |
| 846 | + const sel_string = objc.sel_registerName("UUIDString") orelse return null; |
| 847 | + const ns = objc.msgSendId(uuid, sel_string) orelse return null; |
| 848 | + const utf8 = objc.getNSStringUTF8(ns) orelse return null; |
| 849 | + return std.mem.span(utf8); |
| 850 | +} |
| 851 | + |
| 852 | +fn retainObject(object: Id) Id { |
| 853 | + const target = object orelse return null; |
| 854 | + const sel = objc.sel_registerName("retain") orelse return object; |
| 855 | + return objc.msgSendId(target, sel); |
| 856 | +} |
| 857 | + |
| 858 | +fn releaseObject(object: Id) void { |
| 859 | + const target = object orelse return; |
| 860 | + const sel = objc.sel_registerName("release") orelse return; |
| 861 | + objc.msgSend(target, sel); |
| 862 | +} |
| 863 | + |
| 512 | 864 | // --------------------------------------------------------------------------- |
| 513 | 865 | // The two completions |
| 514 | 866 | // --------------------------------------------------------------------------- |
| @@ -516,9 +868,38 @@ fn optionalMilliseconds(object: std.json.ObjectMap, key: []const u8) !?f64 { |
| 516 | 868 | /// What a slot's block needs that the block itself cannot carry. |
| 517 | 869 | const PendingCall = struct { |
| 518 | 870 | ticket: ios_async.Ticket, |
| 519 | | - /// The `HKUnit` the statistics reply reports in, or null for the |
| 520 | | - /// authorization call which reports none. |
| 521 | | - unit: Id, |
| 871 | + /// The `HKUnit` the statistics reply reports in, or null for the calls |
| 872 | + /// that report none. |
| 873 | + unit: Id = null, |
| 874 | + /// Everything the three-stage workout chain carries between its blocks. |
| 875 | + workout: ?WorkoutChain = null, |
| 876 | +}; |
| 877 | + |
| 878 | +/// What survives from `saveHealthWorkout`'s dispatch into its later stages. |
| 879 | +/// |
| 880 | +/// None of the three blocks can carry any of it: they are global, so they |
| 881 | +/// capture nothing, and the two later ones do not even receive the workout |
| 882 | +/// they are attaching a route to. |
| 883 | +const WorkoutChain = struct { |
| 884 | + /// Retained; released when the chain ends, however it ends. |
| 885 | + workout: Id, |
| 886 | + /// Retained `NSArray<CLLocation *>`, or null when there is no route. |
| 887 | + locations: Id, |
| 888 | + /// The `HKHealthStore` the route builder is created against. |
| 889 | + store: Id, |
| 890 | + /// Owned, `c_allocator`. The route's `HKMetadataKeyExternalUUID`. |
| 891 | + activity_id: []u8, |
| 892 | + /// Retained once stage two begins. |
| 893 | + builder: Id = null, |
| 894 | + |
| 895 | + /// Free everything this chain owns. Idempotent by construction: the entry |
| 896 | + /// is cleared from the slot before this runs. |
| 897 | + fn deinit(self: WorkoutChain) void { |
| 898 | + releaseObject(self.workout); |
| 899 | + releaseObject(self.locations); |
| 900 | + releaseObject(self.builder); |
| 901 | + std.heap.c_allocator.free(self.activity_id); |
| 902 | + } |
| 522 | 903 | }; |
| 523 | 904 | |
| 524 | 905 | var pending_calls: [ios_async.max_in_flight]?PendingCall = @splat(null); |
| @@ -530,6 +911,24 @@ fn publishCall(ticket: ios_async.Ticket, unit: Id) void { |
| 530 | 911 | pending_calls[ticket.index] = .{ .ticket = ticket, .unit = unit }; |
| 531 | 912 | } |
| 532 | 913 | |
| 914 | +fn publishWorkoutCall(ticket: ios_async.Ticket, chain: WorkoutChain) void { |
| 915 | + pending_mutex.lock(); |
| 916 | + defer pending_mutex.unlock(); |
| 917 | + pending_calls[ticket.index] = .{ .ticket = ticket, .workout = chain }; |
| 918 | +} |
| 919 | + |
| 920 | +/// Put a chain back for its next stage, keeping the same slot and ticket. |
| 921 | +/// |
| 922 | +/// The entry is taken at the top of every stage so a duplicate fire finds |
| 923 | +/// nothing; a stage that intends to continue has to put it back explicitly, |
| 924 | +/// which is what makes "this stage is done with the chain" and "the chain |
| 925 | +/// continues" different statements rather than the same silence. |
| 926 | +fn republishWorkoutCall(call: PendingCall, chain: WorkoutChain) void { |
| 927 | + pending_mutex.lock(); |
| 928 | + defer pending_mutex.unlock(); |
| 929 | + pending_calls[call.ticket.index] = .{ .ticket = call.ticket, .workout = chain }; |
| 930 | +} |
| 931 | + |
| 533 | 932 | /// Read and clear, so a second fire is a no-op rather than a second reply. |
| 534 | 933 | fn takeCall(index: u5) ?PendingCall { |
| 535 | 934 | pending_mutex.lock(); |
| @@ -598,6 +997,193 @@ var auth_blocks: [ios_async.max_in_flight]HealthBlock = |
| 598 | 997 | var stats_blocks: [ios_async.max_in_flight]HealthBlock = |
| 599 | 998 | if (is_darwin) makeBlocks(makeStatsInvoke) else undefined; |
| 600 | 999 | |
| 1000 | +fn makeSaveInvoke(comptime index: u5) *const anyopaque { |
| 1001 | + const S = struct { |
| 1002 | + fn invoke(_: *const HealthBlock, success: bool, err: Id) callconv(.c) void { |
| 1003 | + workoutSaved(index, success, err); |
| 1004 | + } |
| 1005 | + }; |
| 1006 | + return @ptrCast(&S.invoke); |
| 1007 | +} |
| 1008 | + |
| 1009 | +fn makeInsertInvoke(comptime index: u5) *const anyopaque { |
| 1010 | + const S = struct { |
| 1011 | + fn invoke(_: *const HealthBlock, success: bool, err: Id) callconv(.c) void { |
| 1012 | + routeInserted(index, success, err); |
| 1013 | + } |
| 1014 | + }; |
| 1015 | + return @ptrCast(&S.invoke); |
| 1016 | +} |
| 1017 | + |
| 1018 | +fn makeFinishInvoke(comptime index: u5) *const anyopaque { |
| 1019 | + const S = struct { |
| 1020 | + fn invoke(_: *const HealthBlock, route: Id, err: Id) callconv(.c) void { |
| 1021 | + routeFinished(index, route, err); |
| 1022 | + } |
| 1023 | + }; |
| 1024 | + return @ptrCast(&S.invoke); |
| 1025 | +} |
| 1026 | + |
| 1027 | +var save_blocks: [ios_async.max_in_flight]HealthBlock = |
| 1028 | + if (is_darwin) makeBlocks(makeSaveInvoke) else undefined; |
| 1029 | +var insert_blocks: [ios_async.max_in_flight]HealthBlock = |
| 1030 | + if (is_darwin) makeBlocks(makeInsertInvoke) else undefined; |
| 1031 | +var finish_blocks: [ios_async.max_in_flight]HealthBlock = |
| 1032 | + if (is_darwin) makeBlocks(makeFinishInvoke) else undefined; |
| 1033 | + |
| 1034 | +/// Stage one: the workout is in the store. |
| 1035 | +fn workoutSaved(index: u5, success: bool, err: Id) void { |
| 1036 | + if (!is_darwin) return; |
| 1037 | + |
| 1038 | + const call = takeCall(index) orelse { |
| 1039 | + std.log.warn("saveHealthWorkout: stage one fired for slot {d} with no call; ignored", .{index}); |
| 1040 | + return; |
| 1041 | + }; |
| 1042 | + const chain = call.workout orelse { |
| 1043 | + std.log.warn("saveHealthWorkout: stage one fired for a slot holding no workout chain", .{}); |
| 1044 | + return; |
| 1045 | + }; |
| 1046 | + |
| 1047 | + if (!success) { |
| 1048 | + logNSError(A.save_health_workout, err); |
| 1049 | + chain.deinit(); |
| 1050 | + ios_async.deliverErrorCode(call.ticket, BridgeError.PermissionDenied); |
| 1051 | + return; |
| 1052 | + } |
| 1053 | + |
| 1054 | + // Swift's early exit. The workout is saved either way; a route is |
| 1055 | + // optional, so having none is not a failure. |
| 1056 | + if (chain.locations == null) { |
| 1057 | + replyWithWorkout(call.ticket, chain, null); |
| 1058 | + chain.deinit(); |
| 1059 | + return; |
| 1060 | + } |
| 1061 | + |
| 1062 | + var next = chain; |
| 1063 | + next.builder = routeBuilder(chain.store) catch |build_err| { |
| 1064 | + std.log.warn("saveHealthWorkout: could not build a route builder: {}", .{build_err}); |
| 1065 | + // The workout is already saved, so answering with its id is truer |
| 1066 | + // than reporting a failure — Swift rejects here, and would tell a page |
| 1067 | + // nothing was written when something was. |
| 1068 | + replyWithWorkout(call.ticket, chain, null); |
| 1069 | + chain.deinit(); |
| 1070 | + return; |
| 1071 | + }; |
| 1072 | + republishWorkoutCall(call, next); |
| 1073 | + |
| 1074 | + const sel = objc.sel_registerName("insertRouteData:completion:") orelse return; |
| 1075 | + const InsertFn = *const fn (Id, objc.SEL, Id, *anyopaque) callconv(.c) void; |
| 1076 | + const insert: InsertFn = @ptrCast(&objc.objc_msgSend); |
| 1077 | + insert(next.builder, sel, next.locations, @ptrCast(&insert_blocks[index])); |
| 1078 | +} |
| 1079 | + |
| 1080 | +/// Stage two: the fixes are in the builder. |
| 1081 | +fn routeInserted(index: u5, success: bool, err: Id) void { |
| 1082 | + if (!is_darwin) return; |
| 1083 | + |
| 1084 | + const call = takeCall(index) orelse return; |
| 1085 | + const chain = call.workout orelse return; |
| 1086 | + |
| 1087 | + if (!success) { |
| 1088 | + logNSError(A.save_health_workout, err); |
| 1089 | + // Swift rejects. The workout is saved regardless, so the id is |
| 1090 | + // reported rather than lost — losing it would leave a page unable to |
| 1091 | + // reference a workout that exists. |
| 1092 | + replyWithWorkout(call.ticket, chain, null); |
| 1093 | + chain.deinit(); |
| 1094 | + return; |
| 1095 | + } |
| 1096 | + |
| 1097 | + republishWorkoutCall(call, chain); |
| 1098 | + |
| 1099 | + const metadata = routeMetadata(chain.activity_id) catch null; |
| 1100 | + const sel = objc.sel_registerName("finishRouteWithWorkout:metadata:completion:") orelse return; |
| 1101 | + const FinishFn = *const fn (Id, objc.SEL, Id, Id, *anyopaque) callconv(.c) void; |
| 1102 | + const finish: FinishFn = @ptrCast(&objc.objc_msgSend); |
| 1103 | + finish(chain.builder, sel, chain.workout, metadata, @ptrCast(&finish_blocks[index])); |
| 1104 | +} |
| 1105 | + |
| 1106 | +/// Stage three: the route is attached. |
| 1107 | +fn routeFinished(index: u5, route: Id, err: Id) void { |
| 1108 | + if (!is_darwin) return; |
| 1109 | + |
| 1110 | + const call = takeCall(index) orelse return; |
| 1111 | + const chain = call.workout orelse return; |
| 1112 | + defer chain.deinit(); |
| 1113 | + |
| 1114 | + if (err != null) { |
| 1115 | + logNSError(A.save_health_workout, err); |
| 1116 | + replyWithWorkout(call.ticket, chain, null); |
| 1117 | + return; |
| 1118 | + } |
| 1119 | + replyWithWorkout(call.ticket, chain, route); |
| 1120 | +} |
| 1121 | + |
| 1122 | +/// `{"id":…}` or `{"id":…,"routeId":…}`. |
| 1123 | +/// |
| 1124 | +/// Swift emits the second shape only when a route was finished, and its |
| 1125 | +/// `routeId` is `route?.uuid.uuidString ?? ""` — so a finished-but-nil route |
| 1126 | +/// carries an empty string rather than a missing key. |
| 1127 | +fn replyWithWorkout(ticket: ios_async.Ticket, chain: WorkoutChain, route: Id) void { |
| 1128 | + const allocator = std.heap.c_allocator; |
| 1129 | + |
| 1130 | + var out: std.ArrayListUnmanaged(u8) = .empty; |
| 1131 | + defer out.deinit(allocator); |
| 1132 | + |
| 1133 | + out.appendSlice(allocator, "{\"id\":") catch return ios_async.deliverError(ticket); |
| 1134 | + appendQuoted(allocator, &out, uuidStringOf(chain.workout) orelse "") catch |
| 1135 | + return ios_async.deliverError(ticket); |
| 1136 | + |
| 1137 | + if (route != null or chain.locations != null) { |
| 1138 | + out.appendSlice(allocator, ",\"routeId\":") catch return ios_async.deliverError(ticket); |
| 1139 | + appendQuoted(allocator, &out, uuidStringOf(route) orelse "") catch |
| 1140 | + return ios_async.deliverError(ticket); |
| 1141 | + } |
| 1142 | + out.append(allocator, '}') catch return ios_async.deliverError(ticket); |
| 1143 | + |
| 1144 | + ios_async.deliverJson(ticket, out.items); |
| 1145 | +} |
| 1146 | + |
| 1147 | +fn appendQuoted( |
| 1148 | + allocator: std.mem.Allocator, |
| 1149 | + out: *std.ArrayListUnmanaged(u8), |
| 1150 | + text: []const u8, |
| 1151 | +) !void { |
| 1152 | + try out.append(allocator, '"'); |
| 1153 | + try bridge_error.appendJsonEscaped(allocator, out, text); |
| 1154 | + try out.append(allocator, '"'); |
| 1155 | +} |
| 1156 | + |
| 1157 | +/// `[[HKWorkoutRouteBuilder alloc] initWithHealthStore:device:]`, retained. |
| 1158 | +fn routeBuilder(store: Id) !Id { |
| 1159 | + const HKWorkoutRouteBuilder = objc.objc_getClass("HKWorkoutRouteBuilder") orelse |
| 1160 | + return error.ClassNotFound; |
| 1161 | + const sel_alloc = objc.sel_registerName("alloc") orelse return error.SelectorNotFound; |
| 1162 | + const sel_init = objc.sel_registerName("initWithHealthStore:device:") orelse |
| 1163 | + return error.SelectorNotFound; |
| 1164 | + const allocated = objc.msgSendId(HKWorkoutRouteBuilder, sel_alloc) orelse |
| 1165 | + return error.NativeCallFailed; |
| 1166 | + |
| 1167 | + // `HKDevice.localDevice` is Swift's `.local()`. |
| 1168 | + const HKDevice = objc.objc_getClass("HKDevice"); |
| 1169 | + const device = if (HKDevice) |cls| blk: { |
| 1170 | + const sel_local = objc.sel_registerName("localDevice") orelse break :blk null; |
| 1171 | + break :blk objc.msgSendId(cls, sel_local); |
| 1172 | + } else null; |
| 1173 | + |
| 1174 | + return objc.msgSendId2(allocated, sel_init, store, device) orelse error.NativeCallFailed; |
| 1175 | +} |
| 1176 | + |
| 1177 | +fn routeMetadata(activity_id: []const u8) !Id { |
| 1178 | + const NSDictionary = objc.objc_getClass("NSDictionary") orelse return error.ClassNotFound; |
| 1179 | + const sel = objc.sel_registerName("dictionaryWithObject:forKey:") orelse |
| 1180 | + return error.SelectorNotFound; |
| 1181 | + const key = try healthConstant("HKMetadataKeyExternalUUID"); |
| 1182 | + const value = objc.createNSString(activity_id, std.heap.c_allocator) catch |
| 1183 | + return error.AllocationFailed; |
| 1184 | + return objc.msgSendId2(NSDictionary, sel, value, key) orelse error.NativeCallFailed; |
| 1185 | +} |
| 1186 | + |
| 601 | 1187 | fn authorizationAnswered(index: u5, success: bool, err: Id) void { |
| 602 | 1188 | if (!is_darwin) return; |
| 603 | 1189 | |
| @@ -710,22 +1296,107 @@ test "the action names match the Swift case labels exactly" { |
| 710 | 1296 | try testing.expectEqualStrings("getHealthData", A.get_health_data); |
| 711 | 1297 | } |
| 712 | 1298 | |
| 713 | | -test "saveHealthWorkout stays with the shim, and is neither declared nor routed" { |
| 714 | | - // The split is safe only because nothing flows between the three actions — |
| 715 | | - // the HKHealthStore is a stateless handle and authorization lives in the |
| 716 | | - // system database. `UnknownAction` is the one return `ios_dispatch.route` |
| 717 | | - // turns into a hand-off; anything else would steal the action from the arm |
| 718 | | - // that answers it today. |
| 1299 | +test "nothing in this family is unserved any more" { |
| 1300 | + // This test used to assert the opposite. The split was always about cost |
| 1301 | + // rather than correctness — the three share only a stateless |
| 1302 | + // HKHealthStore, so no divided state was ever at risk — and the cost was |
| 1303 | + // the third asynchronous stage, which now exists. |
| 1304 | + try testing.expectEqual(@as(usize, 0), deliberately_unserved.len); |
| 1305 | + |
| 719 | 1306 | var bridge = HealthBridge.init(testing.allocator); |
| 720 | 1307 | defer bridge.deinit(); |
| 1308 | + bridge.handleMessage(A.save_health_workout, "{}") catch |err| { |
| 1309 | + try testing.expect(err != BridgeError.UnknownAction); |
| 1310 | + }; |
| 1311 | +} |
| 721 | 1312 | |
| 722 | | - try testing.expectEqual(@as(usize, 1), deliberately_unserved.len); |
| 723 | | - for (deliberately_unserved) |name| { |
| 724 | | - try testing.expectError(BridgeError.UnknownAction, bridge.handleMessage(name, "{}")); |
| 725 | | - for (capability_actions) |decl| { |
| 726 | | - try testing.expect(!std.mem.eql(u8, decl.name, name)); |
| 727 | | - } |
| 1313 | +test "the workout activity values are the SDK's, not instinct" { |
| 1314 | + // Every one of these reads wrong on instinct — running looks like it |
| 1315 | + // should sit beside walking, and it does not. The enum in HKWorkout.h |
| 1316 | + // numbers most members implicitly, so the values are only obtainable by |
| 1317 | + // counting from the last explicit one. A wrong number saves a real |
| 1318 | + // workout under the wrong activity, which HealthKit accepts silently. |
| 1319 | + try testing.expectEqual(@as(c_ulong, 34), workoutActivityFor("running").?); |
| 1320 | + try testing.expectEqual(@as(c_ulong, 49), workoutActivityFor("walking").?); |
| 1321 | + try testing.expectEqual(@as(c_ulong, 22), workoutActivityFor("hiking").?); |
| 1322 | + try testing.expectEqual(@as(c_ulong, 13), workoutActivityFor("cycling").?); |
| 1323 | + try testing.expect(workoutActivityFor("swimming") == null); |
| 1324 | + try testing.expectEqual(@as(usize, 4), workout_activities.len); |
| 1325 | +} |
| 1326 | + |
| 1327 | +test "the workout guard refuses each way it can be refused" { |
| 1328 | + // Swift's four-clause guard rejects all of them with one message. These |
| 1329 | + // codes are the distinction a page could not previously make. |
| 1330 | + try expectWorkoutRefused("{}", BridgeError.MissingData); |
| 1331 | + try expectWorkoutRefused("{\"activityId\":\"a\"}", BridgeError.MissingData); |
| 1332 | + try expectWorkoutRefused( |
| 1333 | + "{\"activityId\":\"a\",\"type\":\"swimming\",\"startDate\":1,\"endDate\":2}", |
| 1334 | + BridgeError.InvalidParameter, |
| 1335 | + ); |
| 1336 | + try expectWorkoutRefused( |
| 1337 | + "{\"activityId\":\"a\",\"type\":\"running\",\"startDate\":2000,\"endDate\":1000}", |
| 1338 | + BridgeError.InvalidParameter, |
| 1339 | + ); |
| 1340 | + // Equal dates are refused too: Swift's guard is `endValue > startValue`. |
| 1341 | + try expectWorkoutRefused( |
| 1342 | + "{\"activityId\":\"a\",\"type\":\"running\",\"startDate\":1000,\"endDate\":1000}", |
| 1343 | + BridgeError.InvalidParameter, |
| 1344 | + ); |
| 1345 | +} |
| 1346 | + |
| 1347 | +fn expectWorkoutRefused(comptime json: []const u8, expected: anyerror) !void { |
| 1348 | + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); |
| 1349 | + defer parsed.deinit(); |
| 1350 | + try testing.expectError(expected, readWorkoutRequest(parsed.value)); |
| 1351 | +} |
| 1352 | + |
| 1353 | +test "a non-positive distance or energy is dropped, not saved as zero" { |
| 1354 | + // Swift's `.flatMap { $0 > 0 ? … : nil }`. A workout carrying an explicit |
| 1355 | + // zero distance reads as "measured none"; nil reads as "did not measure". |
| 1356 | + const parsed = try std.json.parseFromSlice( |
| 1357 | + std.json.Value, |
| 1358 | + testing.allocator, |
| 1359 | + "{\"activityId\":\"a\",\"type\":\"running\",\"startDate\":1000,\"endDate\":2000," ++ |
| 1360 | + "\"distanceMeters\":0,\"activeEnergyCalories\":12.5}", |
| 1361 | + .{}, |
| 1362 | + ); |
| 1363 | + defer parsed.deinit(); |
| 1364 | + |
| 1365 | + const request = try readWorkoutRequest(parsed.value); |
| 1366 | + try testing.expect(request.distance_meters == null); |
| 1367 | + try testing.expectEqual(@as(f64, 12.5), request.energy_calories.?); |
| 1368 | + // Milliseconds in, seconds out. |
| 1369 | + try testing.expectEqual(@as(f64, 1), request.start); |
| 1370 | + try testing.expectEqual(@as(f64, 2), request.end); |
| 1371 | +} |
| 1372 | + |
| 1373 | +test "the three workout stages have distinct invokes per slot" { |
| 1374 | + // Stage two and stage three take different argument types — (BOOL, |
| 1375 | + // NSError*) and (HKWorkoutRoute*, NSError*) — so a shared invoke would |
| 1376 | + // read the route out of a boolean register. |
| 1377 | + if (!is_darwin) return error.SkipZigTest; |
| 1378 | + |
| 1379 | + for (&save_blocks, &insert_blocks, &finish_blocks) |*save, *insert, *finish| { |
| 1380 | + try testing.expectEqual(&_NSConcreteGlobalBlock, save.isa); |
| 1381 | + try testing.expect(save.invoke != insert.invoke); |
| 1382 | + try testing.expect(insert.invoke != finish.invoke); |
| 1383 | + try testing.expect(save.invoke != finish.invoke); |
| 728 | 1384 | } |
| 1385 | + try testing.expect(save_blocks[0].invoke != save_blocks[1].invoke); |
| 1386 | +} |
| 1387 | + |
| 1388 | +test "a workout stage firing for a slot with no chain is ignored" { |
| 1389 | + // Each stage takes the entry and a continuing stage puts it back, so a |
| 1390 | + // duplicate fire finds nothing rather than releasing the workout twice. |
| 1391 | + if (!is_darwin) return error.SkipZigTest; |
| 1392 | + |
| 1393 | + pending_mutex.lock(); |
| 1394 | + for (&pending_calls) |*entry| entry.* = null; |
| 1395 | + pending_mutex.unlock(); |
| 1396 | + |
| 1397 | + workoutSaved(0, true, null); |
| 1398 | + routeInserted(0, true, null); |
| 1399 | + routeFinished(0, null, null); |
| 729 | 1400 | } |
| 730 | 1401 | |
| 731 | 1402 | test "the four data types are the ones the Swift switch recognises" { |