diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 5860a5b..61ad4c8 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -21,7 +21,7 @@ jobs: cache: true - run: flutter pub get - run: dart format --set-exit-if-changed --output=none lib test example/lib example/test - - run: flutter analyze --no-fatal-infos + - run: flutter analyze --fatal-infos - run: flutter test go: diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cc7d8..c570f90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 0.0.8 + +* Android: run the blocking `initBitBox` (Noise pairing handshake) off the serial + MethodChannel task queue so concurrent `getChannelHash` polls are serviced while + init waits for the on-device confirmation. The pairing code now appears in the app + and on the device simultaneously instead of only after confirming on the device + (iOS was already unaffected). `initBitBox` now also propagates the real init result. + ## 0.0.1 * TODO: Describe initial release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..854b688 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,67 @@ +# Contributing to bitbox_flutter + +A Flutter plugin for the BitBox02 hardware wallet (USB on Linux/Android, BLE on iOS). PRs welcome — this guide covers the branching model, the local PR gate, and the release flow so contributions land predictably. + +## Branching model + +- `develop` is the **default branch**. Open every feature, fix, and chore PR against `develop`. +- `main` is the release branch. The only thing that lands there is a `Release: develop -> main` PR, opened automatically by `.github/workflows/auto-release-pr.yaml` on each push to `develop`. +- Feature branches: `feat/`, `fix/`, `chore/`. Branch off the latest `develop`. + +## PR gate + +CI runs three jobs on every PR and every push to `develop` / `main` (`.github/workflows/pull-request.yaml`): + +| Job | What it does | +|---|---| +| `Flutter analyze + test` | `dart format --set-exit-if-changed`, `flutter analyze --no-fatal-infos`, `flutter test` | +| `Go unit tests` | `go vet ./...`, `go test -race -timeout 60s ./...` against `go/api` and `go/u2fhid` | +| `Workflow YAML lint` | `yaml.safe_load` on every `.github/**/*.y*ml` | + +Run the same gate locally — see [TESTING.md → Fast PR gate](TESTING.md#fast-pr-gate). Lint failures upstream are wasted CI minutes; catch them locally. + +## Adding a new platform method + +1. **Dart side**: declare the abstract method on `BitboxUsbPlatform` (`lib/usb/bitbox_usb_platform_interface.dart`) and implement it on `BitboxUsbMethodChannel` (`lib/usb/bitbox_usb_method_channel.dart`). +2. **Go side** (gomobile-exported): add the corresponding function in `go/api/*.go`, wrapped with `defer recoverPanic("")` so a Go-side crash returns a zero value instead of taking the engine down. +3. **Native bridges**: wire the new method through `android/src/main/kotlin/.../MethodCallRegistry.kt` and the iOS handler in `ios/Classes/BitboxFlutterPlugin.swift`. +4. **Testkit**: implement the method on `SimulatedBitboxPlatform` in `lib/testing/bitbox_testkit.dart` so consumer apps can exercise it without hardware. Add a method-name constant to `SimulatedBitboxMethod`. +5. **Tests**: + - `test/bitbox_testkit_test.dart` — Dart-side flow against the simulator. + - `go/api/fake_bitbox_test.go` — Go-side wiring against the `fakeBitboxDevice` fake. +6. **Docs**: if behaviour is non-obvious, add a paragraph to TESTING.md so future contributors know what's covered. + +## Release flow + +Releases are fully automated. Once a contributor's PR is merged into `develop`: + +1. `auto-release-pr.yaml` fires on the push to `develop` and opens (or refreshes) the `Release: develop -> main` PR. +2. A maintainer reviews the rollup and merges it. The merge commit on `main` triggers `auto-tag.yaml`. +3. `auto-tag.yaml` patch-bumps the latest `vX.Y.Z` tag, pushes the new tag, and creates a GitHub Release with auto-generated notes. + +No manual tagging. If a hotfix needs a specific version (skip-patch, force-minor), open an issue first — the auto-tagger is intentionally not overridable to avoid drift. + +## Commit messages + +- Title in imperative present: `fix(ble): drop stale read buffer after timeout`. +- Reference issues by number when applicable: `Closes #123`. +- Body explains **why**, not what (the diff shows the what). +- No `Co-Authored-By` or tool-generated trailers in DFXswiss repos. + +## Hardware-wallet specifics + +- Touching `ios/Classes/Bluetooth.swift` requires extra care: U2FHID `readFrame()` has no SEQ validation, so the BLE bridge MUST not let duplicate notifications leak into the read stream. `go/api/ios_bluetooth_regression_test.go` locks in the 60s read timeout — keep it. +- Touching `go/api/api.go` exported (`//export`) functions: every entry point must start with `defer recoverPanic("")`. The `TestExportedAPIsReturnZeroValuesWithoutDeviceInsteadOfCrashing` test exists to enforce that the gomobile boundary never panics into the host engine. +- New gomobile-exported types: regenerate the iOS xcframework and `android/libs/api.aar` via `run_build_tool_android.sh` and the equivalent iOS pipeline before release. The checked-in artefacts must match the Go module path declared in `go/go.mod`. + +## Code style + +- **Dart**: `dart format` is enforced. No `print` in production code (`lib/`) — use a passed-in logger or remove. `example/lib/` may use `print` for demonstration. +- **Go**: gofmt-clean, `go vet` clean. Errors propagate; the gomobile boundary in `go/api/` is the only place that swallows errors (returning zero values) so panics don't cross into the host engine. +- **Swift / Kotlin**: follow each platform's standard formatter. Native bridges should be thin — push logic into the Go layer wherever possible. + +## Security + +- Never log seed phrases, private keys, or unredacted device serials. +- Don't add network calls — this plugin talks to local hardware only. +- For sensitive issues, mail security@dfx.swiss rather than filing a public issue. diff --git a/TESTING.md b/TESTING.md index 5ebf057..89b9c97 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ these, so it is cheaper to catch them locally first: ```sh # Flutter dart format --set-exit-if-changed --output=none lib test example/lib example/test -flutter analyze --no-fatal-infos +flutter analyze --fatal-infos flutter test # Go (from go/) diff --git a/android/libs/api-sources.jar b/android/libs/api-sources.jar index c9213de..9068770 100644 Binary files a/android/libs/api-sources.jar and b/android/libs/api-sources.jar differ diff --git a/android/libs/api.aar b/android/libs/api.aar index 07877d6..16b8f31 100644 Binary files a/android/libs/api.aar and b/android/libs/api.aar differ diff --git a/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/InitBitBoxOperation.kt b/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/InitBitBoxOperation.kt index 8cdfa46..b01265a 100644 --- a/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/InitBitBoxOperation.kt +++ b/android/src/main/kotlin/com/cakewallet/bitbox_flutter/operations/InitBitBoxOperation.kt @@ -1,6 +1,8 @@ package com.cakewallet.bitbox_flutter.operations import android.content.Context +import android.os.Handler +import android.os.Looper import api.Api import com.cakewallet.bitbox_flutter.BitboxManager import io.flutter.plugin.common.MethodCall @@ -12,7 +14,30 @@ class InitBitBoxOperation(manager: BitboxManager) : UsbMethodCallOperation(manag methodCall: MethodCall, result: MethodChannel.Result ) { - Api.initDevice() - result.success(true) + // Api.initDevice() drives the Noise XX handshake and then blocks until the + // user confirms the pairing on the device. The channel hash (pairing code) is + // already set on the shared device object before that blocking wait, so the + // host can display it immediately. + // + // The "bitbox_usb" MethodChannel runs on a serial background task queue. If we + // called Api.initDevice() directly here, it would occupy that single queue for + // the whole blocking wait, so the concurrent getChannelHash poll could not run + // until init returned — i.e. only AFTER the device confirmation. That made the + // pairing code appear in the app only after the user confirmed on the BitBox, + // instead of simultaneously (Android-only; iOS dispatches init to a background + // queue and is unaffected). + // + // Running the blocking call on its own thread frees the serial queue, so + // getChannelHash is serviced while init is in flight and the code shows on app + // and device at the same time. All other device operations stay on the serial + // queue, preserving the single-cipher nonce ordering. + Thread { + val success = try { + Api.initDevice() + } catch (e: Throwable) { + false + } + Handler(Looper.getMainLooper()).post { result.success(success) } + }.apply { name = "bitbox-init" }.start() } } diff --git a/example/lib/main.dart b/example/lib/main.dart index dd4cf8e..911ac0f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,3 +1,7 @@ +// Demo app uses print() for runtime console feedback during the BitBox flow +// walkthrough. The plugin itself (lib/) must stay print-free. +// ignore_for_file: avoid_print + import 'dart:async'; import 'dart:convert'; import 'dart:ffi'; diff --git a/ios/Api.xcframework/Info.plist b/ios/Api.xcframework/Info.plist index 1a9d8ff..9d3fc23 100644 --- a/ios/Api.xcframework/Info.plist +++ b/ios/Api.xcframework/Info.plist @@ -8,32 +8,32 @@ BinaryPath Api.framework/Api LibraryIdentifier - ios-arm64_x86_64-simulator + ios-arm64 LibraryPath Api.framework SupportedArchitectures arm64 - x86_64 SupportedPlatform ios - SupportedPlatformVariant - simulator BinaryPath Api.framework/Api LibraryIdentifier - ios-arm64 + ios-arm64_x86_64-simulator LibraryPath Api.framework SupportedArchitectures arm64 + x86_64 SupportedPlatform ios + SupportedPlatformVariant + simulator CFBundlePackageType diff --git a/ios/Api.xcframework/ios-arm64/Api.framework/Api b/ios/Api.xcframework/ios-arm64/Api.framework/Api index ad8975e..d6db685 100644 Binary files a/ios/Api.xcframework/ios-arm64/Api.framework/Api and b/ios/Api.xcframework/ios-arm64/Api.framework/Api differ diff --git a/ios/Api.xcframework/ios-arm64/Api.framework/Headers/Api.h b/ios/Api.xcframework/ios-arm64/Api.framework/Headers/Api.h index f0d42d1..09008c9 100644 --- a/ios/Api.xcframework/ios-arm64/Api.framework/Headers/Api.h +++ b/ios/Api.xcframework/ios-arm64/Api.framework/Headers/Api.h @@ -1,7 +1,7 @@ // Objective-C API for talking to the following Go packages // -// github.com/konstantinullrich/bitbox_flutter/api +// github.com/DFXswiss/bitbox_flutter/api // // File is generated by gomobile bind. Do not edit. #ifndef __Api_FRAMEWORK_H__ diff --git a/ios/Api.xcframework/ios-arm64/Api.framework/Headers/Api.objc.h b/ios/Api.xcframework/ios-arm64/Api.framework/Headers/Api.objc.h index 0f80579..d090f8b 100644 --- a/ios/Api.xcframework/ios-arm64/Api.framework/Headers/Api.objc.h +++ b/ios/Api.xcframework/ios-arm64/Api.framework/Headers/Api.objc.h @@ -1,5 +1,5 @@ -// Objective-C API for talking to github.com/konstantinullrich/bitbox_flutter/api Go package. -// gobind -lang=objc github.com/konstantinullrich/bitbox_flutter/api +// Objective-C API for talking to github.com/DFXswiss/bitbox_flutter/api Go package. +// gobind -lang=objc github.com/DFXswiss/bitbox_flutter/api // // File is generated by gobind. Do not edit. diff --git a/ios/Api.xcframework/ios-arm64/Api.framework/Info.plist b/ios/Api.xcframework/ios-arm64/Api.framework/Info.plist index eecabd6..83959d6 100644 --- a/ios/Api.xcframework/ios-arm64/Api.framework/Info.plist +++ b/ios/Api.xcframework/ios-arm64/Api.framework/Info.plist @@ -9,9 +9,9 @@ MinimumOSVersion 100.0 CFBundleShortVersionString - 0.0.1778239382 + 0.0.1779133764 CFBundleVersion - 0.0.1778239382 + 0.0.1779133764 CFBundlePackageType FMWK diff --git a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Api b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Api index 665dcb9..0914e5a 100644 Binary files a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Api and b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Api differ diff --git a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Headers/Api.h b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Headers/Api.h index f0d42d1..09008c9 100644 --- a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Headers/Api.h +++ b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Headers/Api.h @@ -1,7 +1,7 @@ // Objective-C API for talking to the following Go packages // -// github.com/konstantinullrich/bitbox_flutter/api +// github.com/DFXswiss/bitbox_flutter/api // // File is generated by gomobile bind. Do not edit. #ifndef __Api_FRAMEWORK_H__ diff --git a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Headers/Api.objc.h b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Headers/Api.objc.h index 0f80579..d090f8b 100644 --- a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Headers/Api.objc.h +++ b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Headers/Api.objc.h @@ -1,5 +1,5 @@ -// Objective-C API for talking to github.com/konstantinullrich/bitbox_flutter/api Go package. -// gobind -lang=objc github.com/konstantinullrich/bitbox_flutter/api +// Objective-C API for talking to github.com/DFXswiss/bitbox_flutter/api Go package. +// gobind -lang=objc github.com/DFXswiss/bitbox_flutter/api // // File is generated by gobind. Do not edit. diff --git a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Info.plist b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Info.plist index eecabd6..fd4fa65 100644 --- a/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Info.plist +++ b/ios/Api.xcframework/ios-arm64_x86_64-simulator/Api.framework/Info.plist @@ -9,9 +9,9 @@ MinimumOSVersion 100.0 CFBundleShortVersionString - 0.0.1778239382 + 0.0.1779133765 CFBundleVersion - 0.0.1778239382 + 0.0.1779133765 CFBundlePackageType FMWK diff --git a/lib/testing/bitbox_testkit.dart b/lib/testing/bitbox_testkit.dart index 89522d2..9846b16 100644 --- a/lib/testing/bitbox_testkit.dart +++ b/lib/testing/bitbox_testkit.dart @@ -180,66 +180,6 @@ class SimulatedBitboxPlatform extends BitboxUsbPlatform { bool _isOpen = false; bool _channelHashVerified; - static SimulatedBitboxPlatform install({ - List? devices, - Duration defaultDelay = Duration.zero, - bool requireOpen = true, - bool channelHashVerified = false, - bool startScanResult = true, - bool permissionResult = true, - bool openResult = true, - bool initResult = true, - bool channelHashVerifyResult = true, - bool supportsETHResult = true, - bool supportsERC20Result = true, - bool supportsLTCResult = true, - String? channelHash, - Uint8List? masterFingerprint, - String? btcXPub, - String? btcPsbt, - Uint8List? btcMessageSignature, - String? ethAddress, - Uint8List? ethTransactionSignature, - Uint8List? ethEip1559Signature, - Uint8List? ethRlpSignature, - Uint8List? ethMessageSignature, - Uint8List? ethTypedMessageSignature, - Map? delays, - Map? errors, - Map? behaviors, - }) { - final platform = SimulatedBitboxPlatform( - devices: devices, - defaultDelay: defaultDelay, - requireOpen: requireOpen, - channelHashVerified: channelHashVerified, - startScanResult: startScanResult, - permissionResult: permissionResult, - openResult: openResult, - initResult: initResult, - channelHashVerifyResult: channelHashVerifyResult, - supportsETHResult: supportsETHResult, - supportsERC20Result: supportsERC20Result, - supportsLTCResult: supportsLTCResult, - channelHash: channelHash, - masterFingerprint: masterFingerprint, - btcXPub: btcXPub, - btcPsbt: btcPsbt, - btcMessageSignature: btcMessageSignature, - ethAddress: ethAddress, - ethTransactionSignature: ethTransactionSignature, - ethEip1559Signature: ethEip1559Signature, - ethRlpSignature: ethRlpSignature, - ethMessageSignature: ethMessageSignature, - ethTypedMessageSignature: ethTypedMessageSignature, - delays: delays, - errors: errors, - behaviors: behaviors, - ); - BitboxUsbPlatform.instance = platform; - return platform; - } - bool get isOpen => _isOpen; bool get channelHashVerified => _channelHashVerified; @@ -566,6 +506,12 @@ class SimulatedBitboxPlatform extends BitboxUsbPlatform { static Uint8List _copy(Uint8List bytes) => Uint8List.fromList(bytes); } +/// Constructs a [SimulatedBitboxPlatform] with the given overrides and +/// installs it as `BitboxUsbPlatform.instance`. Returns the platform so +/// tests can drive `setDelay` / `throwOn` / `when` and inspect `calls`. +/// +/// Save and restore `BitboxUsbPlatform.instance` in `setUp` / `tearDown` +/// when a suite needs isolation between tests. SimulatedBitboxPlatform installSimulatedBitboxPlatform({ List? devices, Duration defaultDelay = Duration.zero, @@ -593,32 +539,35 @@ SimulatedBitboxPlatform installSimulatedBitboxPlatform({ Map? delays, Map? errors, Map? behaviors, -}) => - SimulatedBitboxPlatform.install( - devices: devices, - defaultDelay: defaultDelay, - requireOpen: requireOpen, - channelHashVerified: channelHashVerified, - startScanResult: startScanResult, - permissionResult: permissionResult, - openResult: openResult, - initResult: initResult, - channelHashVerifyResult: channelHashVerifyResult, - supportsETHResult: supportsETHResult, - supportsERC20Result: supportsERC20Result, - supportsLTCResult: supportsLTCResult, - channelHash: channelHash, - masterFingerprint: masterFingerprint, - btcXPub: btcXPub, - btcPsbt: btcPsbt, - btcMessageSignature: btcMessageSignature, - ethAddress: ethAddress, - ethTransactionSignature: ethTransactionSignature, - ethEip1559Signature: ethEip1559Signature, - ethRlpSignature: ethRlpSignature, - ethMessageSignature: ethMessageSignature, - ethTypedMessageSignature: ethTypedMessageSignature, - delays: delays, - errors: errors, - behaviors: behaviors, - ); +}) { + final platform = SimulatedBitboxPlatform( + devices: devices, + defaultDelay: defaultDelay, + requireOpen: requireOpen, + channelHashVerified: channelHashVerified, + startScanResult: startScanResult, + permissionResult: permissionResult, + openResult: openResult, + initResult: initResult, + channelHashVerifyResult: channelHashVerifyResult, + supportsETHResult: supportsETHResult, + supportsERC20Result: supportsERC20Result, + supportsLTCResult: supportsLTCResult, + channelHash: channelHash, + masterFingerprint: masterFingerprint, + btcXPub: btcXPub, + btcPsbt: btcPsbt, + btcMessageSignature: btcMessageSignature, + ethAddress: ethAddress, + ethTransactionSignature: ethTransactionSignature, + ethEip1559Signature: ethEip1559Signature, + ethRlpSignature: ethRlpSignature, + ethMessageSignature: ethMessageSignature, + ethTypedMessageSignature: ethTypedMessageSignature, + delays: delays, + errors: errors, + behaviors: behaviors, + ); + BitboxUsbPlatform.instance = platform; + return platform; +} diff --git a/lib/usb/bitbox_usb_method_channel.dart b/lib/usb/bitbox_usb_method_channel.dart index a20752e..bb21d1f 100644 --- a/lib/usb/bitbox_usb_method_channel.dart +++ b/lib/usb/bitbox_usb_method_channel.dart @@ -39,12 +39,10 @@ class MethodChannelBitboxUsb extends BitboxUsbPlatform { @override Future open(BitboxDevice usbDevice) async { - print('[bitbox_flutter] open ${usbDevice.productName}'); final open = await methodChannel.invokeMethod( 'open', usbDevice.toMap(), ); - print('[bitbox_flutter] open $open'); return open ?? false; } @@ -58,9 +56,7 @@ class MethodChannelBitboxUsb extends BitboxUsbPlatform { @override Future initBitBox() async { - print('[bitbox_flutter] initBitBox'); final result = await methodChannel.invokeMethod('initBitBox'); - print('[bitbox_flutter] initBitBox $result'); return result ?? false; }