Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3d738e5
Replace Skip dependencies with AndroidSwiftUI
colemancda Jul 21, 2026
30ef86c
Remove Skip environment configuration
colemancda Jul 21, 2026
015ebc6
Remove Skip module configuration from the app target
colemancda Jul 21, 2026
17f673a
Remove Skip module configuration from the UI target
colemancda Jul 21, 2026
26b1bb3
Remove Skip module configuration from the model target
colemancda Jul 21, 2026
8dc796f
Add a logger shim to replace the Skip logging framework
colemancda Jul 21, 2026
174cc0c
Drop Skip bridging from the app entry point
colemancda Jul 21, 2026
0878048
Remove Skip template scaffolding and Compose interop
colemancda Jul 21, 2026
4314bba
Import Observation directly in Store
colemancda Jul 21, 2026
edc9633
Import Observation directly in PluginManager
colemancda Jul 21, 2026
b9da091
Import Observation directly in CentralListViewModel
colemancda Jul 21, 2026
47329b4
Import Observation directly in PeripheralViewModel
colemancda Jul 21, 2026
81692b8
Import AndroidSwiftUI on Android in AsyncButton
colemancda Jul 21, 2026
784bbee
Import AndroidSwiftUI on Android in AttributeCell
colemancda Jul 21, 2026
a731e0a
Import AndroidSwiftUI on Android in AttributeValueCell
colemancda Jul 21, 2026
db8205c
Import AndroidSwiftUI on Android in AttributeValuesSection
colemancda Jul 21, 2026
bd30a83
Import AndroidSwiftUI on Android in CentralCell
colemancda Jul 21, 2026
3f1e66f
Import AndroidSwiftUI on Android in CentralList
colemancda Jul 21, 2026
7a4d5db
Import AndroidSwiftUI on Android in CharacteristicView
colemancda Jul 21, 2026
bb12818
Import AndroidSwiftUI on Android in DecodedFieldsView
colemancda Jul 21, 2026
1ed3f3a
Import AndroidSwiftUI on Android in DescriptorView
colemancda Jul 21, 2026
115432f
Import AndroidSwiftUI on Android in PeripheralView
colemancda Jul 21, 2026
0754f23
Import AndroidSwiftUI on Android in PluginsView
colemancda Jul 21, 2026
b996037
Import AndroidSwiftUI on Android in ServiceView
colemancda Jul 21, 2026
8df5434
Import AndroidSwiftUI on Android in WriteAttributeView
colemancda Jul 21, 2026
debbef7
Document the Skip to AndroidSwiftUI migration
colemancda Jul 21, 2026
764d084
Raise the per-call plugin deadline to tolerate contended hosts
colemancda Jul 21, 2026
c784e5e
Match the raised plugin deadline default
colemancda Jul 21, 2026
d6cc255
Serialize the GATT plugin stress suite
colemancda Jul 21, 2026
105b243
Record the AndroidSwiftUI additions and their known gaps
colemancda Jul 21, 2026
9c05a94
Inline app identity into the xcconfig after removing Skip.env
colemancda Jul 21, 2026
86df59d
Add CI for tests, iOS archive and the Android plugin engine build
colemancda Jul 21, 2026
13b2ed4
Document what blocks an Android app archive
colemancda Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# NOTE: every job here fails on a clean checkout until PureSwift/AndroidBluetooth#4 is merged.
# AndroidBluetooth requests the `AndroidManifest` product from `Android`, but it moved to
# `swift-android-native` (PureSwift/Android#40), and SwiftPM validates the whole package graph even
# for dependencies that are conditional on `.android`:
# error: product 'AndroidManifest' required by package 'androidbluetooth' ... not found
# That is a one-line manifest fix upstream, not a problem with this repo.
name: CI

on:
push:
branches: [master]
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Test
runs-on: macos-15
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Build
run: swift build --build-tests
- name: Test
run: swift test

ios-archive:
name: iOS Archive
runs-on: macos-15
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

# WasmKit declares `.treatAllWarnings(as: .error)` for Apple platforms, which conflicts with
# the `-suppress-warnings` Xcode passes to package dependencies:
# error: Conflicting options '-warnings-as-errors' and '-suppress-warnings'
# The setting only reaches package targets from the command line, so it cannot live in the
# xcconfig or the project.
- name: Archive
working-directory: Darwin
run: |
xcodebuild archive \
-project BluetoothExplorer.xcodeproj \
-scheme "BluetoothExplorer App" \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/BluetoothExplorer.xcarchive" \
-skipPackagePluginValidation \
-skipMacroValidation \
SWIFT_SUPPRESS_WARNINGS=NO \
CODE_SIGNING_ALLOWED=NO

- name: Verify archive contents
run: |
APP="$RUNNER_TEMP/BluetoothExplorer.xcarchive/Products/Applications/BluetoothExplorer.app"
test -d "$APP" || { echo "::error::archive did not produce BluetoothExplorer.app"; exit 1; }
# The bundled WASM parser plugins must ship inside the app.
find "$APP" -name '*.wasm' -print | tee /tmp/wasm-list
test -s /tmp/wasm-list || { echo "::error::no bundled .wasm plugins in the archive"; exit 1; }

- uses: actions/upload-artifact@v4
with:
name: ios-archive
path: ${{ runner.temp }}/BluetoothExplorer.xcarchive
retention-days: 14

android-package:
name: Android Swift Package
runs-on: macos-15
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Install Swift Android SDK
run: |
brew install skiptools/skip/skip || (brew update && brew install skiptools/skip/skip)
skip android sdk install --version 6.3.3

# Only the plugin engine is built here. The full app does not yet cross-compile for Android:
# AndroidBluetooth's `@JavaImplementation` macro generates invalid code for its scan callback,
# and WasmKit's SystemExtras target does not compile against the Android sysroot
# (`st_mode` is UInt32 there while swift-system's CInterop.Mode is UInt16). Neither is
# reachable from this target. See Documentation/AndroidSwiftUIMigration.md.
- name: Build plugin engine for Android
run: swift build --swift-sdk swift-6.3.3-RELEASE_android --target BluetoothExplorerPluginEngine
23 changes: 14 additions & 9 deletions Darwin/BluetoothExplorer.xcconfig
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
#include "../Skip.env"

// Set the action that will be executed as part of the Xcode Run Script phase
// Setting to "launch" will build and run the app in the first open Android emulator or device
// Setting to "build" will just run gradle build, but will not launch the app
// Setting to "none" will completely disable the build and launch of the Android app
//SKIP_ACTION = launch
//SKIP_ACTION = build
SKIP_ACTION = none
// App identity. These previously lived in Skip.env, which was shared with the Android build; with
// Skip removed they are declared here and the Android project carries its own copy.

// PRODUCT_NAME must match the app's Swift module name
PRODUCT_NAME = BluetoothExplorer

// The unique id for the app
PRODUCT_BUNDLE_IDENTIFIER = org.pureswift.bluetoothexplorer

// The semantic version of the app
MARKETING_VERSION = 1.0.0

// The build number specifying the internal app version
CURRENT_PROJECT_VERSION = 1

ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor
Expand Down
126 changes: 126 additions & 0 deletions Documentation/AndroidSwiftUIMigration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Migrating off Skip to AndroidSwiftUI

The app previously used [Skip](https://skip.tools) to compile its SwiftUI codebase for Android. It
now uses the system SwiftUI on Apple platforms and [PureSwift/AndroidSwiftUI](https://github.com/PureSwift/AndroidSwiftUI)
on Android.

## What Skip was doing

Skip's footprint was smaller than it appeared — nine `import` lines and one `Logger` — but it also
owned the build:

| Skip piece | Replacement |
|---|---|
| `SkipFuseUI` — SwiftUI for Android | `AndroidSwiftUI`, linked only `.when(platforms: [.android])` |
| `SkipModel` — Observation for Android | `import Observation` directly; the Swift Android SDK ships `Observation.swiftmodule` |
| `SkipFuse` — Foundation shims, `Logger` | `AppLogger` in `Sources/BluetoothExplorer/Logging.swift` (`os.Logger` on Apple, print-based elsewhere) |
| `skipstone` build plugin + three `skip.yml` | nothing — plain SwiftPM targets |
| `/* SKIP @bridge */` on the root view and app delegate | nothing — `Darwin/Sources/Main.swift` was already a real `@main` App |
| `ComposeView` / `#if SKIP` Compose interop in `ContentView` | removed with the rest of the Skip template scaffolding |
| `Skip.env` | removed |

Views now import conditionally:

```swift
#if canImport(SwiftUI)
import SwiftUI
#else
import AndroidSwiftUI
#endif
```

## The dependency-graph side effect

This removed the whole Skip fork stack — `skip-fuse`, `skip-fuse-ui`, `skip-android-bridge`,
`skip-ui`, `skip-foundation`, `skip-model`, and their transitive Java/JNI packages. The package
dropped from ~30 resolved dependencies to 20, and **the graph now resolves from a clean checkout**:

```sh
swift package resolve # exit 0, no mirrors, no SWIFTPM_ENABLE_MACROS=0
```

Every blocker recorded in `Documentation/DependencyState.md` traced back to that stack:

- the `swift-android-native` two-URL conflict needed `skip-android-bridge` as one of the two
claimants — with Skip gone there is only one claimant left;
- the `swift-java` / `swift-syntax` 603-vs-602 conflict came in through the same graph;
- `skip-fuse-ui` was the package that could not build against current `skip-ui`.

Two `PureSwift` fixes are still required and are still open as PRs — `PureSwift/Android#43` and
`PureSwift/AndroidBluetooth#4`. Until they merge, local SwiftPM mirrors pointing at fixed clones are
needed; `AndroidBluetooth` otherwise fails with `product 'AndroidManifest' ... not found in package
'Android'`.

## What AndroidSwiftUI needed

The app's UI relies on SwiftUI that AndroidSwiftUI did not implement. These were added to
AndroidSwiftUI first (branch `feature/swiftui-containers`):

- `NavigationStack` — reusing the existing `NavigationContext`, so `NavigationLink` push and
hardware-back pop are shared with `NavigationView` rather than duplicated
- `LazyVStack` / `LazyHStack` — eager, mapping onto the same LinearLayout path as `VStack`/`HStack`
- `TabView` with `.tabItem` and selection — tab items are read back by walking the modifier chain
for trait-writing modifiers; only the selected tab is mounted
- `.sheet(isPresented:content:)` — a full-screen overlay in the same view hierarchy rather than an
Android `Dialog`, because the fiber renderer mounts children into the parent's `ViewGroup` and has
no path to a `Dialog`'s separate decor view
- `.refreshable` — stores a `RefreshAction` in the environment as SwiftUI does, but no gesture
triggers it: binding `SwipeRefreshLayout` would need an androidx dependency the package lacks
- Swift **Observation** support — `.environment(object)` and `@Environment(Type.self)`, with
invalidation driven by `withObservationTracking`

`.fileImporter` is not needed on Android: the plugin import button is already gated behind
`#if !os(Android)`.

### Known gaps in those additions

All four compile and were verified to build together for Android, but none has been exercised on a
device or emulator. Notable limitations, each documented in the source:

- **Observation**: `@Bindable` is not implemented, and `@Environment(Store.self) var store: Store?`
(the optional form) is unsupported — a missing injection traps rather than yielding `nil`.
- **TabView**: unselected tabs are unmounted, so their `@State` resets; no `TabViewStyle`, paging or
badges.
- **Sheet**: no animation, detents or drag-to-dismiss, and no `@Environment(\.dismiss)` — content
must dismiss itself through the binding.
- **NavigationStack**: `NavigationStack(path:)` and value-based `navigationDestination(for:)` are
not implemented; `NavigationContext.path` holds type-erased views, not a `Hashable` data path.

## Platform status

- **Apple platforms** — the package resolves and every target builds with no Skip involvement.
- **Android** — the dependency wiring is in place, but the Android app build has not been verified
end to end. It additionally needs the two PureSwift PRs above, and the Kotlin JNI peers under
`Sources/BluetoothExplorer/Skip/` (`ScanCallback.kt`, `BluetoothGattCallback.kt`) rehomed into the
Android app project — they are peers for `AndroidBluetooth`, unrelated to Skip, and were only
living in that directory because skipstone collected them.

## Building an Android app archive

CI (`.github/workflows/ci.yml`) archives the iOS app but does **not** produce an Android `.aab`.
Three things block it, each verified by trying:

1. **The app does not cross-compile for Android.** `swift build --swift-sdk … --target BluetoothExplorer`
fails inside `AndroidBluetooth`: its `@JavaImplementation` macro generates invalid Swift for
`LowEnergyScanCallback.swiftOnScanFailed(error:)` — the parameter named `error` is emitted into a
context where it parses as a keyword, producing
`declaration name '…' is not covered by macro 'JavaImplementation'`. This is an upstream
swift-java/AndroidBluetooth bug.
2. **A whole-package Android build additionally fails in WasmKit's `SystemExtras`**: it does
`st_mode & S_IFMT`, but `st_mode` is `UInt32` in the Android sysroot while swift-system's
`CInterop.Mode` is `UInt16`. This target is not reachable from the app, so building specific
targets avoids it; a plain `swift build` does not.
3. **The Android project is still Skip's.** `Android/settings.gradle.kts` shells out to
`skip plugin --prebuild`, and `Android/app/build.gradle.kts` applies `skip-build-plugin` with a
`skip { }` block that reads the now-deleted `Skip.env`. With Skip removed none of that resolves.

Replacing (3) means writing a conventional Android app project, using
[AndroidSwiftUI's `Demo/`](https://github.com/PureSwift/AndroidSwiftUI/tree/master/Demo) as the
template: its `app/src/main/java/com/pureswift/swiftandroid/` Kotlin classes (`MainActivity`,
`Application`, `NativeLibrary`, `ListViewAdapter`, `Fragment`, …) are the peers AndroidSwiftUI's
`@JavaClass` bindings bind to, and `build-swift.sh` shows the packaging step — build the Swift
package for the target architecture, then copy the resulting `.so` into
`app/src/main/jniLibs/<abi>/`. The Kotlin JNI peers currently under `Sources/BluetoothExplorer/Skip/`
(`ScanCallback.kt`, `BluetoothGattCallback.kt`) belong in that project too.

None of this is worth doing until (1) is fixed upstream, since the `.so` cannot be produced.
39 changes: 17 additions & 22 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// swift-tools-version: 6.2
// This is a Skip (https://skip.tools) package.
import PackageDescription

let package = Package(
Expand All @@ -10,13 +9,12 @@ let package = Package(
.library(name: "BluetoothExplorer", type: .dynamic, targets: ["BluetoothExplorer"])
],
dependencies: [
.package(url: "https://source.skip.tools/skip.git", from: "1.7.1"),
.package(url: "https://source.skip.tools/skip-model.git", from: "1.0.0"),
.package(url: "https://github.com/MillerTechnologyPeru/skip-fuse-ui.git", branch: "feature/pureswift"),
.package(url: "https://github.com/MillerTechnologyPeru/skip-fuse.git", branch: "feature/pureswift"),
.package(url: "https://github.com/PureSwift/GATT.git", branch: "master"),
.package(url: "https://github.com/PureSwift/AndroidBluetooth.git", branch: "master"),
.package(url: "https://github.com/PureSwift/Bluetooth.git", from: "7.2.0"),
// SwiftUI for Android. Apple platforms use the system SwiftUI instead, so this is only
// linked for .android — see the conditional target dependencies below.
.package(url: "https://github.com/PureSwift/AndroidSwiftUI.git", branch: "master"),
.package(url: "https://github.com/swiftwasm/WasmKit.git", .upToNextMinor(from: "0.3.1"))
],
targets: [
Expand All @@ -25,24 +23,25 @@ let package = Package(
dependencies: [
"BluetoothExplorerUI",
.product(
name: "SkipFuseUI",
package: "skip-fuse-ui"
name: "AndroidSwiftUI",
package: "AndroidSwiftUI",
condition: .when(platforms: [.android])
)
],
resources: [.process("Resources")],
plugins: [.plugin(name: "skipstone", package: "skip")]
resources: [.process("Resources")]
),
.target(
name: "BluetoothExplorerUI",
dependencies: [
"BluetoothExplorerModel",
"BluetoothExplorerPluginEngine",
.product(name: "SkipModel", package: "skip-model"),
.product(name: "SkipFuse", package: "skip-fuse"),
.product(name: "SkipFuseUI", package: "skip-fuse-ui")
.product(
name: "AndroidSwiftUI",
package: "AndroidSwiftUI",
condition: .when(platforms: [.android])
)
],
resources: [.process("Resources")],
plugins: [.plugin(name: "skipstone", package: "skip")]
resources: [.process("Resources")]
),
.target(
name: "BluetoothExplorerPluginEngine",
Expand Down Expand Up @@ -71,16 +70,12 @@ let package = Package(
condition: .when(platforms: [.android])
),
.product(
name: "SkipFuse",
package: "skip-fuse"
),
.product(
name: "SkipModel",
package: "skip-model"
name: "AndroidSwiftUI",
package: "AndroidSwiftUI",
condition: .when(platforms: [.android])
)
],
resources: [.process("Resources")],
plugins: [.plugin(name: "skipstone", package: "skip")]
resources: [.process("Resources")]
),
.testTarget(
name: "BluetoothExplorerPluginEngineTests",
Expand Down
23 changes: 0 additions & 23 deletions Skip.env

This file was deleted.

Loading
Loading