also looking at this
fix(native): interaction, audio, document and checkpoint gaps stranded after #401
#415
19 files
+987
-50
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -174,7 +174,7 @@ and linear gradients are applied by UIKit with the same style keys as Android; | ||
| 174 | 174 | `elevation` remains Android-specific. Gradient layers follow the native view's |
| 175 | 175 | bounds and corners through layout changes. |
| 176 | 176 | |
| 177 | The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `SegmentedControl`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. Dynamic `craft.shortcuts.set`/`clear` calls on an all-native screen resolve `false` (the typed `unsupported` result); configure static `appShortcuts` in `craft.config.json` when the app shell should publish home-screen quick actions. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md). | |
| 177 | The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `SegmentedControl`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. Dynamic `craft.shortcuts.set`/`clear` are not installed by the all-native host yet; configure static `appShortcuts` in `craft.config.json` to publish home-screen quick actions. Runtime shortcut registration remains tracked in #412. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md). | |
| 178 | 178 | |
| 179 | 179 | `FlatList` rows are authored as keyed `:for` child rows in native STX; the |
| 180 | 180 | maintained protocol does not serialize `data`/`renderItem` callbacks. |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import JavaScriptCore |
| 2 | import AVFoundation | |
| 2 | 3 | import XCTest |
| 3 | 4 | @testable import NativeRender |
| 4 | 5 | |
| @@ -7,6 +8,60 @@ import XCTest | ||
| 7 | 8 | /// samples the engine would play. |
| 8 | 9 | @MainActor |
| 9 | 10 | final class NativeAudioUnitTests: XCTestCase { |
| 11 | private func withSound(_ test: (CraftNativeSounds) -> Void) throws { | |
| 12 | let file = FileManager.default.temporaryDirectory.appendingPathComponent("sound-\(UUID().uuidString).wav") | |
| 13 | defer { try? FileManager.default.removeItem(at: file) } | |
| 14 | let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate: 8000, channels: 1)) | |
| 15 | let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 16000)) | |
| 16 | buffer.frameLength = 16000 | |
| 17 | buffer.floatChannelData?[0].initialize(repeating: 0, count: 16000) | |
| 18 | do { | |
| 19 | let output = try AVAudioFile(forWriting: file, settings: format.settings) | |
| 20 | try output.write(from: buffer) | |
| 21 | } | |
| 22 | let service = CraftNativeSounds() | |
| 23 | defer { service.close() } | |
| 24 | service.play(id: "tone", url: file, options: ["loop": true]) { _ in } | |
| 25 | test(service) | |
| 26 | } | |
| 27 | ||
| 28 | private func interrupt(_ type: AVAudioSession.InterruptionType, resume: Bool = false) { | |
| 29 | NotificationCenter.default.post(name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance(), userInfo: [ | |
| 30 | AVAudioSessionInterruptionTypeKey: NSNumber(value: type.rawValue), | |
| 31 | AVAudioSessionInterruptionOptionKey: NSNumber(value: resume ? AVAudioSession.InterruptionOptions.shouldResume.rawValue : 0), | |
| 32 | ]) | |
| 33 | } | |
| 34 | ||
| 35 | func testSoundWithoutAutomaticResumePermissionCanBeResumedExplicitly() throws { | |
| 36 | try withSound { service in | |
| 37 | interrupt(.began) | |
| 38 | interrupt(.ended) | |
| 39 | // Another unrelated interruption must not auto-resume this handle. | |
| 40 | interrupt(.began) | |
| 41 | interrupt(.ended, resume: true) | |
| 42 | XCTAssertTrue(service.resume(id: "tone")) | |
| 43 | XCTAssertFalse(service.resume(id: "tone")) | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | func testExplicitSoundPauseSurvivesInterruptionResumePermission() throws { | |
| 48 | try withSound { service in | |
| 49 | interrupt(.began) | |
| 50 | XCTAssertTrue(service.pause(id: "tone")) | |
| 51 | interrupt(.ended, resume: true) | |
| 52 | XCTAssertTrue(service.resume(id: "tone")) | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | func testSoundAutomaticallyResumesOnlyWithSystemPermission() throws { | |
| 57 | try withSound { service in | |
| 58 | interrupt(.began) | |
| 59 | interrupt(.ended, resume: true) | |
| 60 | XCTAssertFalse(service.resume(id: "tone"), "already resumed automatically") | |
| 61 | XCTAssertFalse(service.resume(id: "missing")) | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 10 | 65 | private final class VoiceRecorder: CraftVoiceRecorder { |
| 11 | 66 | private let lock = NSLock() |
| 12 | 67 | private var records = 0 |
| @@ -329,11 +329,14 @@ final class CraftNativeSounds { | ||
| 329 | 329 | } |
| 330 | 330 | guard type == AVAudioSession.InterruptionType.ended.rawValue else { return } |
| 331 | 331 | let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? NSNumber)?.uintValue ?? 0 |
| 332 | guard options & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 else { return } | |
| 332 | let shouldResume = options & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 | |
| 333 | 333 | self.sounds.values.forEach { sound in |
| 334 | 334 | guard sound.interrupted else { return } |
| 335 | 335 | sound.interrupted = false |
| 336 | sound.player.play() | |
| 336 | // Without permission to auto-resume, keep a resumable paused | |
| 337 | // handle. A later unrelated interruption must not restart it. | |
| 338 | sound.paused = !shouldResume | |
| 339 | if shouldResume { sound.player.play() } | |
| 337 | 340 | } |
| 338 | 341 | } |
| 339 | 342 | } |