Skip to content

Commit 9e34039

Browse files
authored
Merge pull request #22 from MillerTechnologyPeru/feature/androidswiftui
Replace Skip with AndroidSwiftUI
2 parents a17188b + 13b2ed4 commit 9e34039

31 files changed

Lines changed: 376 additions & 168 deletions

.github/workflows/ci.yml

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# NOTE: every job here fails on a clean checkout until PureSwift/AndroidBluetooth#4 is merged.
2+
# AndroidBluetooth requests the `AndroidManifest` product from `Android`, but it moved to
3+
# `swift-android-native` (PureSwift/Android#40), and SwiftPM validates the whole package graph even
4+
# for dependencies that are conditional on `.android`:
5+
# error: product 'AndroidManifest' required by package 'androidbluetooth' ... not found
6+
# That is a one-line manifest fix upstream, not a problem with this repo.
7+
name: CI
8+
9+
on:
10+
push:
11+
branches: [master]
12+
pull_request:
13+
workflow_dispatch:
14+
15+
concurrency:
16+
group: ${{ github.workflow }}-${{ github.ref }}
17+
cancel-in-progress: true
18+
19+
jobs:
20+
test:
21+
name: Test
22+
runs-on: macos-15
23+
timeout-minutes: 45
24+
steps:
25+
- uses: actions/checkout@v4
26+
- uses: maxim-lobanov/setup-xcode@v1
27+
with:
28+
xcode-version: latest-stable
29+
- name: Build
30+
run: swift build --build-tests
31+
- name: Test
32+
run: swift test
33+
34+
ios-archive:
35+
name: iOS Archive
36+
runs-on: macos-15
37+
timeout-minutes: 60
38+
steps:
39+
- uses: actions/checkout@v4
40+
- uses: maxim-lobanov/setup-xcode@v1
41+
with:
42+
xcode-version: latest-stable
43+
44+
# WasmKit declares `.treatAllWarnings(as: .error)` for Apple platforms, which conflicts with
45+
# the `-suppress-warnings` Xcode passes to package dependencies:
46+
# error: Conflicting options '-warnings-as-errors' and '-suppress-warnings'
47+
# The setting only reaches package targets from the command line, so it cannot live in the
48+
# xcconfig or the project.
49+
- name: Archive
50+
working-directory: Darwin
51+
run: |
52+
xcodebuild archive \
53+
-project BluetoothExplorer.xcodeproj \
54+
-scheme "BluetoothExplorer App" \
55+
-destination 'generic/platform=iOS' \
56+
-archivePath "$RUNNER_TEMP/BluetoothExplorer.xcarchive" \
57+
-skipPackagePluginValidation \
58+
-skipMacroValidation \
59+
SWIFT_SUPPRESS_WARNINGS=NO \
60+
CODE_SIGNING_ALLOWED=NO
61+
62+
- name: Verify archive contents
63+
run: |
64+
APP="$RUNNER_TEMP/BluetoothExplorer.xcarchive/Products/Applications/BluetoothExplorer.app"
65+
test -d "$APP" || { echo "::error::archive did not produce BluetoothExplorer.app"; exit 1; }
66+
# The bundled WASM parser plugins must ship inside the app.
67+
find "$APP" -name '*.wasm' -print | tee /tmp/wasm-list
68+
test -s /tmp/wasm-list || { echo "::error::no bundled .wasm plugins in the archive"; exit 1; }
69+
70+
- uses: actions/upload-artifact@v4
71+
with:
72+
name: ios-archive
73+
path: ${{ runner.temp }}/BluetoothExplorer.xcarchive
74+
retention-days: 14
75+
76+
android-package:
77+
name: Android Swift Package
78+
runs-on: macos-15
79+
timeout-minutes: 60
80+
steps:
81+
- uses: actions/checkout@v4
82+
- uses: maxim-lobanov/setup-xcode@v1
83+
with:
84+
xcode-version: latest-stable
85+
86+
- name: Install Swift Android SDK
87+
run: |
88+
brew install skiptools/skip/skip || (brew update && brew install skiptools/skip/skip)
89+
skip android sdk install --version 6.3.3
90+
91+
# Only the plugin engine is built here. The full app does not yet cross-compile for Android:
92+
# AndroidBluetooth's `@JavaImplementation` macro generates invalid code for its scan callback,
93+
# and WasmKit's SystemExtras target does not compile against the Android sysroot
94+
# (`st_mode` is UInt32 there while swift-system's CInterop.Mode is UInt16). Neither is
95+
# reachable from this target. See Documentation/AndroidSwiftUIMigration.md.
96+
- name: Build plugin engine for Android
97+
run: swift build --swift-sdk swift-6.3.3-RELEASE_android --target BluetoothExplorerPluginEngine

Darwin/BluetoothExplorer.xcconfig

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
#include "../Skip.env"
2-
3-
// Set the action that will be executed as part of the Xcode Run Script phase
4-
// Setting to "launch" will build and run the app in the first open Android emulator or device
5-
// Setting to "build" will just run gradle build, but will not launch the app
6-
// Setting to "none" will completely disable the build and launch of the Android app
7-
//SKIP_ACTION = launch
8-
//SKIP_ACTION = build
9-
SKIP_ACTION = none
1+
// App identity. These previously lived in Skip.env, which was shared with the Android build; with
2+
// Skip removed they are declared here and the Android project carries its own copy.
3+
4+
// PRODUCT_NAME must match the app's Swift module name
5+
PRODUCT_NAME = BluetoothExplorer
6+
7+
// The unique id for the app
8+
PRODUCT_BUNDLE_IDENTIFIER = org.pureswift.bluetoothexplorer
9+
10+
// The semantic version of the app
11+
MARKETING_VERSION = 1.0.0
12+
13+
// The build number specifying the internal app version
14+
CURRENT_PROJECT_VERSION = 1
1015

1116
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
1217
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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.

Package.swift

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
// swift-tools-version: 6.2
2-
// This is a Skip (https://skip.tools) package.
32
import PackageDescription
43

54
let package = Package(
@@ -10,13 +9,12 @@ let package = Package(
109
.library(name: "BluetoothExplorer", type: .dynamic, targets: ["BluetoothExplorer"])
1110
],
1211
dependencies: [
13-
.package(url: "https://source.skip.tools/skip.git", from: "1.7.1"),
14-
.package(url: "https://source.skip.tools/skip-model.git", from: "1.0.0"),
15-
.package(url: "https://github.com/MillerTechnologyPeru/skip-fuse-ui.git", branch: "feature/pureswift"),
16-
.package(url: "https://github.com/MillerTechnologyPeru/skip-fuse.git", branch: "feature/pureswift"),
1712
.package(url: "https://github.com/PureSwift/GATT.git", branch: "master"),
1813
.package(url: "https://github.com/PureSwift/AndroidBluetooth.git", branch: "master"),
1914
.package(url: "https://github.com/PureSwift/Bluetooth.git", from: "7.2.0"),
15+
// SwiftUI for Android. Apple platforms use the system SwiftUI instead, so this is only
16+
// linked for .android — see the conditional target dependencies below.
17+
.package(url: "https://github.com/PureSwift/AndroidSwiftUI.git", branch: "master"),
2018
.package(url: "https://github.com/swiftwasm/WasmKit.git", .upToNextMinor(from: "0.3.1"))
2119
],
2220
targets: [
@@ -25,24 +23,25 @@ let package = Package(
2523
dependencies: [
2624
"BluetoothExplorerUI",
2725
.product(
28-
name: "SkipFuseUI",
29-
package: "skip-fuse-ui"
26+
name: "AndroidSwiftUI",
27+
package: "AndroidSwiftUI",
28+
condition: .when(platforms: [.android])
3029
)
3130
],
32-
resources: [.process("Resources")],
33-
plugins: [.plugin(name: "skipstone", package: "skip")]
31+
resources: [.process("Resources")]
3432
),
3533
.target(
3634
name: "BluetoothExplorerUI",
3735
dependencies: [
3836
"BluetoothExplorerModel",
3937
"BluetoothExplorerPluginEngine",
40-
.product(name: "SkipModel", package: "skip-model"),
41-
.product(name: "SkipFuse", package: "skip-fuse"),
42-
.product(name: "SkipFuseUI", package: "skip-fuse-ui")
38+
.product(
39+
name: "AndroidSwiftUI",
40+
package: "AndroidSwiftUI",
41+
condition: .when(platforms: [.android])
42+
)
4343
],
44-
resources: [.process("Resources")],
45-
plugins: [.plugin(name: "skipstone", package: "skip")]
44+
resources: [.process("Resources")]
4645
),
4746
.target(
4847
name: "BluetoothExplorerPluginEngine",
@@ -71,16 +70,12 @@ let package = Package(
7170
condition: .when(platforms: [.android])
7271
),
7372
.product(
74-
name: "SkipFuse",
75-
package: "skip-fuse"
76-
),
77-
.product(
78-
name: "SkipModel",
79-
package: "skip-model"
73+
name: "AndroidSwiftUI",
74+
package: "AndroidSwiftUI",
75+
condition: .when(platforms: [.android])
8076
)
8177
],
82-
resources: [.process("Resources")],
83-
plugins: [.plugin(name: "skipstone", package: "skip")]
78+
resources: [.process("Resources")]
8479
),
8580
.testTarget(
8681
name: "BluetoothExplorerPluginEngineTests",

Skip.env

Lines changed: 0 additions & 23 deletions
This file was deleted.

0 commit comments

Comments
 (0)