|
| 1 | +# Migrating off Skip to AndroidSwiftUI |
| 2 | + |
| 3 | +The app previously used [Skip](https://skip.tools) to compile its SwiftUI codebase for Android. It |
| 4 | +now uses the system SwiftUI on Apple platforms and [PureSwift/AndroidSwiftUI](https://github.com/PureSwift/AndroidSwiftUI) |
| 5 | +on Android. |
| 6 | + |
| 7 | +## What Skip was doing |
| 8 | + |
| 9 | +Skip's footprint was smaller than it appeared — nine `import` lines and one `Logger` — but it also |
| 10 | +owned the build: |
| 11 | + |
| 12 | +| Skip piece | Replacement | |
| 13 | +|---|---| |
| 14 | +| `SkipFuseUI` — SwiftUI for Android | `AndroidSwiftUI`, linked only `.when(platforms: [.android])` | |
| 15 | +| `SkipModel` — Observation for Android | `import Observation` directly; the Swift Android SDK ships `Observation.swiftmodule` | |
| 16 | +| `SkipFuse` — Foundation shims, `Logger` | `AppLogger` in `Sources/BluetoothExplorer/Logging.swift` (`os.Logger` on Apple, print-based elsewhere) | |
| 17 | +| `skipstone` build plugin + three `skip.yml` | nothing — plain SwiftPM targets | |
| 18 | +| `/* SKIP @bridge */` on the root view and app delegate | nothing — `Darwin/Sources/Main.swift` was already a real `@main` App | |
| 19 | +| `ComposeView` / `#if SKIP` Compose interop in `ContentView` | removed with the rest of the Skip template scaffolding | |
| 20 | +| `Skip.env` | removed | |
| 21 | + |
| 22 | +Views now import conditionally: |
| 23 | + |
| 24 | +```swift |
| 25 | +#if canImport(SwiftUI) |
| 26 | +import SwiftUI |
| 27 | +#else |
| 28 | +import AndroidSwiftUI |
| 29 | +#endif |
| 30 | +``` |
| 31 | + |
| 32 | +## The dependency-graph side effect |
| 33 | + |
| 34 | +This removed the whole Skip fork stack — `skip-fuse`, `skip-fuse-ui`, `skip-android-bridge`, |
| 35 | +`skip-ui`, `skip-foundation`, `skip-model`, and their transitive Java/JNI packages. The package |
| 36 | +dropped from ~30 resolved dependencies to 20, and **the graph now resolves from a clean checkout**: |
| 37 | + |
| 38 | +```sh |
| 39 | +swift package resolve # exit 0, no mirrors, no SWIFTPM_ENABLE_MACROS=0 |
| 40 | +``` |
| 41 | + |
| 42 | +Every blocker recorded in `Documentation/DependencyState.md` traced back to that stack: |
| 43 | + |
| 44 | +- the `swift-android-native` two-URL conflict needed `skip-android-bridge` as one of the two |
| 45 | + claimants — with Skip gone there is only one claimant left; |
| 46 | +- the `swift-java` / `swift-syntax` 603-vs-602 conflict came in through the same graph; |
| 47 | +- `skip-fuse-ui` was the package that could not build against current `skip-ui`. |
| 48 | + |
| 49 | +Two `PureSwift` fixes are still required and are still open as PRs — `PureSwift/Android#43` and |
| 50 | +`PureSwift/AndroidBluetooth#4`. Until they merge, local SwiftPM mirrors pointing at fixed clones are |
| 51 | +needed; `AndroidBluetooth` otherwise fails with `product 'AndroidManifest' ... not found in package |
| 52 | +'Android'`. |
| 53 | + |
| 54 | +## What AndroidSwiftUI needed |
| 55 | + |
| 56 | +The app's UI relies on SwiftUI that AndroidSwiftUI did not implement. These were added to |
| 57 | +AndroidSwiftUI first (branch `feature/swiftui-containers`): |
| 58 | + |
| 59 | +- `NavigationStack` — reusing the existing `NavigationContext`, so `NavigationLink` push and |
| 60 | + hardware-back pop are shared with `NavigationView` rather than duplicated |
| 61 | +- `LazyVStack` / `LazyHStack` — eager, mapping onto the same LinearLayout path as `VStack`/`HStack` |
| 62 | +- `TabView` with `.tabItem` and selection — tab items are read back by walking the modifier chain |
| 63 | + for trait-writing modifiers; only the selected tab is mounted |
| 64 | +- `.sheet(isPresented:content:)` — a full-screen overlay in the same view hierarchy rather than an |
| 65 | + Android `Dialog`, because the fiber renderer mounts children into the parent's `ViewGroup` and has |
| 66 | + no path to a `Dialog`'s separate decor view |
| 67 | +- `.refreshable` — stores a `RefreshAction` in the environment as SwiftUI does, but no gesture |
| 68 | + triggers it: binding `SwipeRefreshLayout` would need an androidx dependency the package lacks |
| 69 | +- Swift **Observation** support — `.environment(object)` and `@Environment(Type.self)`, with |
| 70 | + invalidation driven by `withObservationTracking` |
| 71 | + |
| 72 | +`.fileImporter` is not needed on Android: the plugin import button is already gated behind |
| 73 | +`#if !os(Android)`. |
| 74 | + |
| 75 | +### Known gaps in those additions |
| 76 | + |
| 77 | +All four compile and were verified to build together for Android, but none has been exercised on a |
| 78 | +device or emulator. Notable limitations, each documented in the source: |
| 79 | + |
| 80 | +- **Observation**: `@Bindable` is not implemented, and `@Environment(Store.self) var store: Store?` |
| 81 | + (the optional form) is unsupported — a missing injection traps rather than yielding `nil`. |
| 82 | +- **TabView**: unselected tabs are unmounted, so their `@State` resets; no `TabViewStyle`, paging or |
| 83 | + badges. |
| 84 | +- **Sheet**: no animation, detents or drag-to-dismiss, and no `@Environment(\.dismiss)` — content |
| 85 | + must dismiss itself through the binding. |
| 86 | +- **NavigationStack**: `NavigationStack(path:)` and value-based `navigationDestination(for:)` are |
| 87 | + not implemented; `NavigationContext.path` holds type-erased views, not a `Hashable` data path. |
| 88 | + |
| 89 | +## Platform status |
| 90 | + |
| 91 | +- **Apple platforms** — the package resolves and every target builds with no Skip involvement. |
| 92 | +- **Android** — the dependency wiring is in place, but the Android app build has not been verified |
| 93 | + end to end. It additionally needs the two PureSwift PRs above, and the Kotlin JNI peers under |
| 94 | + `Sources/BluetoothExplorer/Skip/` (`ScanCallback.kt`, `BluetoothGattCallback.kt`) rehomed into the |
| 95 | + Android app project — they are peers for `AndroidBluetooth`, unrelated to Skip, and were only |
| 96 | + living in that directory because skipstone collected them. |
| 97 | + |
| 98 | +## Building an Android app archive |
| 99 | + |
| 100 | +CI (`.github/workflows/ci.yml`) archives the iOS app but does **not** produce an Android `.aab`. |
| 101 | +Three things block it, each verified by trying: |
| 102 | + |
| 103 | +1. **The app does not cross-compile for Android.** `swift build --swift-sdk … --target BluetoothExplorer` |
| 104 | + fails inside `AndroidBluetooth`: its `@JavaImplementation` macro generates invalid Swift for |
| 105 | + `LowEnergyScanCallback.swiftOnScanFailed(error:)` — the parameter named `error` is emitted into a |
| 106 | + context where it parses as a keyword, producing |
| 107 | + `declaration name '…' is not covered by macro 'JavaImplementation'`. This is an upstream |
| 108 | + swift-java/AndroidBluetooth bug. |
| 109 | +2. **A whole-package Android build additionally fails in WasmKit's `SystemExtras`**: it does |
| 110 | + `st_mode & S_IFMT`, but `st_mode` is `UInt32` in the Android sysroot while swift-system's |
| 111 | + `CInterop.Mode` is `UInt16`. This target is not reachable from the app, so building specific |
| 112 | + targets avoids it; a plain `swift build` does not. |
| 113 | +3. **The Android project is still Skip's.** `Android/settings.gradle.kts` shells out to |
| 114 | + `skip plugin --prebuild`, and `Android/app/build.gradle.kts` applies `skip-build-plugin` with a |
| 115 | + `skip { }` block that reads the now-deleted `Skip.env`. With Skip removed none of that resolves. |
| 116 | + |
| 117 | +Replacing (3) means writing a conventional Android app project, using |
| 118 | +[AndroidSwiftUI's `Demo/`](https://github.com/PureSwift/AndroidSwiftUI/tree/master/Demo) as the |
| 119 | +template: its `app/src/main/java/com/pureswift/swiftandroid/` Kotlin classes (`MainActivity`, |
| 120 | +`Application`, `NativeLibrary`, `ListViewAdapter`, `Fragment`, …) are the peers AndroidSwiftUI's |
| 121 | +`@JavaClass` bindings bind to, and `build-swift.sh` shows the packaging step — build the Swift |
| 122 | +package for the target architecture, then copy the resulting `.so` into |
| 123 | +`app/src/main/jniLibs/<abi>/`. The Kotlin JNI peers currently under `Sources/BluetoothExplorer/Skip/` |
| 124 | +(`ScanCallback.kt`, `BluetoothGattCallback.kt`) belong in that project too. |
| 125 | + |
| 126 | +None of this is worth doing until (1) is fixed upstream, since the `.so` cannot be produced. |
0 commit comments