Compare commits

..

62 Commits

Author SHA1 Message Date
Jonas Bark
3343325195 adjust reinstating saved keymaps 2025-03-30 20:06:44 +02:00
Jonas Bark
edda16dc06 adjust reinstating saved keymaps 2025-03-30 19:30:34 +02:00
Jonas Bark
7a3d120123 Keyboard: no point in catching modifiers 2025-03-30 18:58:38 +02:00
Jonas Bark
92419c9182 potential fix for #6 2025-03-30 18:44:55 +02:00
Jonas Bark
68bb5bf371 Zwift Ride: adjust on off button detection 2025-03-30 18:30:15 +02:00
Jonas Bark
b0d8bfcadd potential fix for #7 2025-03-30 18:29:01 +02:00
Jonas Bark
a58ad1daf6 potential fix for Bluetooth device detection 2025-03-30 16:53:34 +02:00
Jonas Bark
657c6056c4 potential fix for Bluetooth device detection 2025-03-30 16:08:03 +02:00
Jonas Bark
84daba8902 potential fix for Bluetooth device detection 2025-03-30 16:03:01 +02:00
Jonas Bark
3e37f8a269 update readme 2025-03-30 15:17:52 +02:00
Jonas Bark
28d178c4be update changelog + version 2025-03-30 15:04:00 +02:00
Jonas Bark
f560cd5930 don't call getSystemDevices on Web 2025-03-30 15:02:04 +02:00
Jonas Bark
dbf24c6cd3 fix touch placement coordinate systems (closes #4) 2025-03-30 15:00:24 +02:00
Jonas Bark
0a4989ca47 fix touch placement coordinate systems (closes #4) 2025-03-30 14:59:42 +02:00
Jonas Bark
507dbf5d0f cleanup code 2025-03-30 14:40:08 +02:00
Jonas Bark
536f36f4e7 update Zwift Ride decoding based on Feedback from @JayyajGH
fixes #3
2025-03-30 14:30:38 +02:00
Jonas Bark
c523ba2287 Android: allow user to adjust touch placements 2025-03-30 14:26:51 +02:00
Jonas Bark
a3f1cbb3b1 reconnect to existing BLE connection, also fallback to name only if manufacturerData isn't available 2025-03-30 14:09:41 +02:00
Jonas Bark
561bb2f0f4 allow adding custom keymap and store it 2025-03-30 13:36:40 +02:00
Jonas Bark
dbbd1b5f2c another potential keyboard fix, Zwift Play adjustment 2025-03-29 21:15:43 +01:00
Jonas Bark
7f6ec2f732 revert keyboard change 2025-03-29 19:48:51 +01:00
Jonas Bark
e8649203cf Zwift Ride: more work on https://github.com/jonasbark/swiftcontrol/issues/3#issuecomment-2763633261 2025-03-29 17:42:08 +01:00
Jonas Bark
765b0c3d6d Zwift Ride: more work on https://github.com/jonasbark/swiftcontrol/issues/3#issuecomment-2763633261 2025-03-29 17:38:41 +01:00
Jonas Bark
248c40731c potential fix for https://github.com/jonasbark/swiftcontrol/issues/1#issuecomment-2763382744 2025-03-29 15:12:10 +01:00
Jonas Bark
cde01a4863 fix build 2025-03-29 15:07:01 +01:00
Jonas Bark
1ee8a0188b try to resolve https://github.com/jonasbark/swiftcontrol/issues/3#issuecomment-2763361122 2025-03-29 14:54:16 +01:00
Jonas Bark
32e8fc9bf5 try to resolve https://github.com/jonasbark/swiftcontrol/issues/3#issuecomment-2763361122 2025-03-29 14:37:07 +01:00
Jonas Bark
3c87d895c5 fix missing permissions for Android 2025-03-29 14:34:39 +01:00
Jonas Bark
82dd8a9b48 sign Android APK 2025-03-29 14:16:17 +01:00
Jonas Bark
0996506fd1 attempt to fix #3 2025-03-29 13:49:22 +01:00
Jonas Bark
3ee38ee1e2 update changelog 2025-03-29 13:07:18 +01:00
Jonas Bark
cf9401d81c resolve https://github.com/jonasbark/swiftcontrol/issues/2 2025-03-29 12:53:27 +01:00
Jonas Bark
dbed5cc247 refactor to use universal_ble 2025-03-29 12:26:47 +01:00
Jonas Bark
625a77fd74 Merge branch 'universal_ble' 2025-03-29 11:51:28 +01:00
Jonas Bark
f7856cd71a fix build, add zwift_ride.html for debugging 2025-03-29 11:04:19 +01:00
Jonas Bark
3e56c32376 attempt to fix windows ble issue 2025-03-29 10:54:26 +01:00
Jonas Bark
56ecad07cd remove dependency again 2025-03-29 10:28:00 +01:00
Jonas Bark
de8ef5f10b attempt to fix windows ble issue 2025-03-29 02:02:32 +01:00
Jonas Bark
022df97b89 fix indicate on macOS 2025-03-28 23:15:56 +01:00
Jonas Bark
662480ef4c integrate universal_ble 2025-03-28 23:14:06 +01:00
Jonas Bark
446743c4cf windows: fix bad imports, naming conflict 2025-03-28 20:57:11 +01:00
Jonas Bark
ebb670b328 windows: find by name only 2025-03-28 20:35:08 +01:00
Jonas Bark
74d253cdc3 reset build.yml 2025-03-28 18:28:58 +01:00
Jonas Bark
a368b4a271 hint to downloads 2025-03-28 18:28:27 +01:00
Jonas Bark
16a71f4c65 rename APK 2025-03-28 18:20:04 +01:00
Jonas Bark
3790bca2e7 zip that directory 2025-03-28 17:44:36 +01:00
Jonas Bark
34fd830859 zip that directory 2025-03-28 17:44:24 +01:00
Jonas Bark
9c0be2fe7a zip that directory 2025-03-28 17:43:18 +01:00
Jonas Bark
e5f7f593b2 build windows 2025-03-28 17:27:20 +01:00
Jonas Bark
b189a9a435 try to sign macOS app 2025-03-28 17:08:15 +01:00
Jonas Bark
479172f34d try to sign macOS app 2025-03-28 17:02:53 +01:00
Jonas Bark
9eb2a43cd2 try to sign macOS app 2025-03-28 16:43:47 +01:00
Jonas Bark
1110df67d9 try to notarize macOS app 2025-03-28 16:16:49 +01:00
Jonas Bark
9a88313199 control media (Android only for now), add menu 2025-03-28 15:24:25 +01:00
jonasbark
f41560e285 Update README.md 2025-03-28 14:45:37 +01:00
Jonas Bark
4d37a1aca1 Merge remote-tracking branch 'origin/main' 2025-03-28 14:34:05 +01:00
Jonas Bark
01fbb0e663 github actions 2025-03-28 14:33:57 +01:00
jonasbark
91e4bd7475 Create FUNDING.yml 2025-03-28 14:17:28 +01:00
Jonas Bark
3a111499f1 github actions 2025-03-28 13:48:01 +01:00
Jonas Bark
4548981d1b github actions 2025-03-28 13:45:56 +01:00
Jonas Bark
d026d26ae9 github actions 2025-03-28 13:05:13 +01:00
Jonas Bark
cca7cd7962 github actions 2025-03-28 13:04:39 +01:00
100 changed files with 2414 additions and 2723 deletions

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
github: [jonasbark]
open_collective: jonas-bark1
custom: ["https://paypal.me/boni"]

View File

@@ -10,11 +10,40 @@ jobs:
name: Build & Release
runs-on: macos-latest
permissions:
id-token: write
pages: write
contents: write
steps:
#1 Checkout Repository
- name: Checkout Repository
uses: actions/checkout@v3
- name: Install certificates
env:
DEVELOPER_ID_APPLICATION_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64_MAC }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
# create variables
DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH=$RUNNER_TEMP/build_developerID_application_certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/pg-signing.keychain-db
# import certificate and provisioning profile from secrets
echo -n "$DEVELOPER_ID_APPLICATION_P12_BASE64_MAC" | base64 --decode --output $DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH
# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# security default-keychain -s $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# import certificate to keychain
security import $DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
#2 Setup Java
- name: Set Up Java
uses: actions/setup-java@v3.12.0
@@ -32,16 +61,33 @@ jobs:
- name: Install Dependencies
run: flutter pub get
#8 Build app ( macos Build )
- name: Build App
run: flutter build macos --release
- name: Code Signing
run: /usr/bin/codesign --deep --force -s "$DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY" --options runtime SwiftControl.app -v
working-directory: build/macos/Build/Products/Release
env:
DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY: ${{ secrets.DEVELOPER_ID_APPLICATION_SIGNING_IDENTITY }}
- name: Decode Keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore;
echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
#6 Building APK
- name: Build APK
run: flutter build apk --release
#8 Build app ( macos Build )
- name: Build IPA
run: flutter build macos --release
- name: Build Web
run: flutter build web --release
run: flutter build web --release --base-href "/swiftcontrol/"
- name: Handle archives
run: |
cp build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/SwiftControl.android.apk
cd build/macos/Build/Products/Release/
zip -r SwiftControl.macos.zip SwiftControl.app/
#9 Upload Artifacts
- name: Upload Artifacts
@@ -49,8 +95,8 @@ jobs:
with:
name: Releases
path: |
build/app/outputs/flutter-apk/app-release.apk
build/macos/Build/Products/Release/SwiftControl.app
build/app/outputs/flutter-apk/SwiftControl.android.apk
build/macos/Build/Products/Release/SwiftControl.macos.zip
#10 Extract Version
- name: Extract version from pubspec.yaml
@@ -81,7 +127,7 @@ jobs:
- name: Create Release
uses: ncipollo/release-action@v1
with:
artifacts: "build/app/outputs/flutter-apk/app-release.apk,build/macos/Build/Products/Release/SwiftControl.app"
artifacts: "build/app/outputs/flutter-apk/SwiftControl.android.apk,build/macos/Build/Products/Release/SwiftControl.macos.zip"
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}
@@ -93,3 +139,66 @@ jobs:
- name: Web Deploy
uses: actions/deploy-pages@v4
windows:
needs: build
name: Build & Release on Windows
runs-on: windows-latest
steps:
#1 Checkout Repository
- name: Checkout Repository
uses: actions/checkout@v3
#2 Setup Java
- name: Set Up Java
uses: actions/setup-java@v3.12.0
with:
distribution: 'oracle'
java-version: '17'
#3 Setup Flutter
- name: Set Up Flutter
uses: subosito/flutter-action@v2
with:
channel: 'stable'
#4 Install Dependencies
- name: Install Dependencies
run: flutter pub get
- name: Build App
run: flutter build windows
- name: Zip directory (Windows)
shell: pwsh
run: |
Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath "build/windows/x64/runner/Release/SwiftControl.windows.zip"
#9 Upload Artifacts
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
overwrite: true
name: Releases
path: |
build/windows/x64/runner/Release/SwiftControl.windows.zip
#10 Extract Version
- name: Extract version from pubspec.yaml (Windows)
shell: pwsh
run: |
$version = Select-String '^version: ' pubspec.yaml | ForEach-Object {
($_ -split ' ')[1].Trim()
}
echo "VERSION=$version" >> $env:GITHUB_ENV
# add artifact to release
- name: Create Release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: "build/windows/x64/runner/Release/SwiftControl.windows.zip"
tag: v${{ env.VERSION }}
token: ${{ secrets.TOKEN }}

2
.gitignore vendored
View File

@@ -12,6 +12,8 @@
.swiftpm/
migrate_working_dir/
android/keystore.properties
# IntelliJ related
*.iml
*.ipr

32
CHANGELOG.md Normal file
View File

@@ -0,0 +1,32 @@
### 1.1.4 (2025-03-30)
- another potential fix for #6
### 1.1.3 (2025-03-30)
- Windows: fix custom keyboard profile recreation after restart, also warn when choosing MyWhoosh profile (may fix #7)
- Zwift Ride: button map adjustments to prevent double shifting
- potential fix for #6
### 1.1.1 (2025-03-30)
- potential fix for Bluetooth device detection
### 1.1.0 (2025-03-30)
- Windows & macOS: allow setting custom keymap and store the setting
- Android: allow customizing the touch area, so it can work with any device without guesswork where the buttons are (#4)
- Zwift Ride: update Zwift Ride decoding based on Feedback from @JayyajGH (#3)
### 1.0.6 (2025-03-29)
- Another potential keyboard fix for Windows
- Zwift Play: actually also use the dedicated shift buttons
### 1.0.5 (2025-03-29)
- Zwift Ride: remap the shifter buttons to the correct values
### 1.0.0+4 (2025-03-29)
- Zwift Ride: attempt to fix button parsing
- Android: fix missing permissions
- Windows: potential fix for key press issues
### 1.0.0+3 (2025-03-29)
- Windows: fix connection by using a different Bluetooth stack (issue #1)
- Android: fix non-working touch propagation (issue #2)

View File

@@ -2,32 +2,47 @@
<img src="logo.jpg" alt="SwiftControl Logo"/>
### Description
## Description
With SwiftControl you can control your favorite trainer app using your Zwift Click, Zwift Ride or Zwift Play devices. Primarily useful to perform virtual gear shifting.
### Supported Apps
https://github.com/user-attachments/assets/1f81b674-1628-4763-ad66-5f3ed7a3f159
## Downloads
Get the latest version here: https://github.com/jonasbark/swiftcontrol/releases
## Supported Apps
- MyWhoosh
- indieVelo / Training Peaks
- let me know if you know others that can benefit
- any other:
- Android: you can customize the gear shifting touch points in the app
- Desktop: you can customize the keyboard shortcuts in the app
### Supported Devices
## Supported Devices
- Zwift Click
- Zwift Ride
- Zwift Play
### Supported Platforms
## Supported Platforms
- Android
- macOS
- Windows
- [Web](https://jonasbark.github.io/swiftcontrol/) (you won't be able to do much)
### How does it work?
## How does it work?
The app connects to your Zwift device automatically.
- When using Android a "click" on a certain part of the screen is simulated to trigger the action.
- When using macOS or Windows a keyboard click is used to trigger the action. Typically + and - keys are used to shift gears, while MyWhoosh uses K and I keys.
### TODO
- test Zwift Ride
## Donate
Please consider donating to support the development of this app.
[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://paypal.me/boni)
## TODO
- implement more actions for Play + Ride
- shorebird?

View File

@@ -45,6 +45,19 @@ class FlutterError (
val details: Any? = null
) : Throwable()
enum class MediaAction(val raw: Int) {
PLAY_PAUSE(0),
NEXT(1),
VOLUME_UP(2),
VOLUME_DOWN(3);
companion object {
fun ofRaw(raw: Int): MediaAction? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class WindowEvent (
val packageName: String,
@@ -85,6 +98,11 @@ private open class AccessibilityApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
MediaAction.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
WindowEvent.fromList(it)
}
@@ -94,8 +112,12 @@ private open class AccessibilityApiPigeonCodec : StandardMessageCodec() {
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is WindowEvent -> {
is MediaAction -> {
stream.write(129)
writeValue(stream, value.raw)
}
is WindowEvent -> {
stream.write(130)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
@@ -110,6 +132,7 @@ interface Accessibility {
fun hasPermission(): Boolean
fun openPermissions()
fun performTouch(x: Double, y: Double)
fun controlMedia(action: MediaAction)
companion object {
/** The codec used by Accessibility. */
@@ -170,6 +193,24 @@ interface Accessibility {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.controlMedia$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val actionArg = args[0] as MediaAction
val wrapped: List<Any?> = try {
api.controlMedia(actionArg)
listOf(null)
} catch (exception: Throwable) {
wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@@ -1,6 +1,7 @@
package de.jonasbark.accessibility
import Accessibility
import MediaAction
import PigeonEventSink
import StreamEventsStreamHandler
import WindowEvent
@@ -64,6 +65,19 @@ class AccessibilityPlugin: FlutterPlugin, MethodCallHandler, Accessibility {
Observable.toService?.performTouch(x = x, y = y) ?: error("Service not running")
}
override fun controlMedia(action: MediaAction) {
val audioService = context.getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
when (action) {
MediaAction.PLAY_PAUSE -> {
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
}
MediaAction.NEXT -> audioService.dispatchMediaKeyEvent(android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_MEDIA_NEXT))
MediaAction.VOLUME_DOWN -> audioService.adjustVolume(android.media.AudioManager.ADJUST_LOWER, android.media.AudioManager.FLAG_SHOW_UI)
MediaAction.VOLUME_UP -> audioService.adjustVolume(android.media.AudioManager.ADJUST_RAISE, android.media.AudioManager.FLAG_SHOW_UI)
}
}
}
class EventListener : StreamEventsStreamHandler(), Receiver {

View File

@@ -9,7 +9,6 @@ import android.os.Build
import android.util.Log
import android.view.ViewConfiguration
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityEvent.CONTENT_CHANGE_TYPE_PANE_DISAPPEARED
import android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
@@ -26,11 +25,13 @@ class AccessibilityService : AccessibilityService(), Listener {
Observable.toService = null
}
private val ignorePackages = listOf("com.android.systemui", "com.android.launcher", "com.android.settings")
override fun onAccessibilityEvent(event: AccessibilityEvent) {
if (event.packageName == null || rootInActiveWindow == null) {
return
}
if (event.contentChangeTypes == CONTENT_CHANGE_TYPE_PANE_DISAPPEARED) {
if (event.eventType != TYPE_WINDOW_STATE_CHANGED || event.packageName in ignorePackages) {
// we're not interested
return
}

View File

@@ -7,8 +7,12 @@ abstract class Accessibility {
void openPermissions();
void performTouch(double x, double y);
void controlMedia(MediaAction action);
}
enum MediaAction { playPause, next, volumeUp, volumeDown }
class WindowEvent {
final String packageName;
final int windowHeight;

View File

@@ -15,6 +15,13 @@ PlatformException _createConnectionError(String channelName) {
);
}
enum MediaAction {
playPause,
next,
volumeUp,
volumeDown,
}
class WindowEvent {
WindowEvent({
required this.packageName,
@@ -77,8 +84,11 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is WindowEvent) {
} else if (value is MediaAction) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is WindowEvent) {
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
@@ -89,6 +99,9 @@ class _PigeonCodec extends StandardMessageCodec {
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
final int? value = readValue(buffer) as int?;
return value == null ? null : MediaAction.values[value];
case 130:
return WindowEvent.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
@@ -184,6 +197,29 @@ class Accessibility {
return;
}
}
Future<void> controlMedia(MediaAction action) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.accessibility.Accessibility.controlMedia$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[action]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else {
return;
}
}
}
Stream<WindowEvent> streamEvents( {String instanceName = ''}) {

View File

@@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.*
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,6 +8,11 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
android {
namespace = "de.jonasbark.swift_play"
compileSdk = flutter.compileSdkVersion
@@ -33,11 +41,18 @@ android {
versionName = flutter.versionName
}
signingConfigs {
create("config") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file("../${keystoreProperties["storeFile"] as String}")
storePassword = keystoreProperties["storePassword"] as String
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("config")
}
}
}

View File

@@ -10,7 +10,7 @@
<!-- legacy for Android 11 or lower -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>

View File

@@ -1,29 +0,0 @@
---
name: Bug Report
about: Create a report to help us improve
title: "fix: "
labels: bug
---
**Description**
A clear and concise description of what the bug is.
**Steps To Reproduce**
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected Behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional Context**
Add any other context about the problem here.

View File

@@ -1,14 +0,0 @@
---
name: Build System
about: Changes that affect the build system or external dependencies
title: "build: "
labels: build
---
**Description**
Describe what changes need to be done to the build system and why.
**Requirements**
- [ ] The build system is passing

View File

@@ -1,14 +0,0 @@
---
name: Chore
about: Other changes that don't modify src or test files
title: "chore: "
labels: chore
---
**Description**
Clearly describe what change is needed and why. If this changes code then please use another issue type.
**Requirements**
- [ ] No functional changes to the code

View File

@@ -1,14 +0,0 @@
---
name: Continuous Integration
about: Changes to the CI configuration files and scripts
title: "ci: "
labels: ci
---
**Description**
Describe what changes need to be done to the ci/cd system and why.
**Requirements**
- [ ] The ci system is passing

View File

@@ -1 +0,0 @@
blank_issues_enabled: false

View File

@@ -1,14 +0,0 @@
---
name: Documentation
about: Improve the documentation so all collaborators have a common understanding
title: "docs: "
labels: documentation
---
**Description**
Clearly describe what documentation you are looking to add or improve.
**Requirements**
- [ ] Requirements go here

View File

@@ -1,18 +0,0 @@
---
name: Feature Request
about: A new feature to be added to the project
title: "feat: "
labels: feature
---
**Description**
Clearly describe what you are looking to add. The more context the better.
**Requirements**
- [ ] Checklist of requirements to be fulfilled
**Additional Context**
Add any other context or screenshots about the feature request go here.

View File

@@ -1,14 +0,0 @@
---
name: Performance Update
about: A code change that improves performance
title: "perf: "
labels: performance
---
**Description**
Clearly describe what code needs to be changed and what the performance impact is going to be. Bonus point's if you can tie this directly to user experience.
**Requirements**
- [ ] There is no drop in test coverage.

View File

@@ -1,14 +0,0 @@
---
name: Refactor
about: A code change that neither fixes a bug nor adds a feature
title: "refactor: "
labels: refactor
---
**Description**
Clearly describe what needs to be refactored and why. Please provide links to related issues (bugs or upcoming features) in order to help prioritize.
**Requirements**
- [ ] There is no drop in test coverage.

View File

@@ -1,16 +0,0 @@
---
name: Revert Commit
about: Reverts a previous commit
title: "revert: "
labels: revert
---
**Description**
Provide a link to a PR/Commit that you are looking to revert and why.
**Requirements**
- [ ] Change has been reverted
- [ ] No change in test coverage has happened
- [ ] A new ticket is created for any follow on work that needs to happen

View File

@@ -1,14 +0,0 @@
---
name: Style Changes
about: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc)
title: "style: "
labels: style
---
**Description**
Clearly describe what you are looking to change and why.
**Requirements**
- [ ] There is no drop in test coverage.

View File

@@ -1,14 +0,0 @@
---
name: Test
about: Adding missing tests or correcting existing tests
title: "test: "
labels: test
---
**Description**
List out the tests that need to be added or changed. Please also include any information as to why this was not covered in the past.
**Requirements**
- [ ] There is no drop in test coverage.

View File

@@ -1,27 +0,0 @@
<!--
Thanks for contributing!
Provide a description of your changes below and a general summary in the title
Please look at the following checklist to ensure that your PR can be accepted quickly:
-->
## Status
**READY/IN DEVELOPMENT/HOLD**
## Description
<!--- Describe your changes in detail -->
## Type of Change
<!--- Put an `x` in all the boxes that apply: -->
- [ ] ✨ New feature (non-breaking change which adds functionality)
- [ ] 🛠️ Bug fix (non-breaking change which fixes an issue)
- [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change)
- [ ] 🧹 Code refactor
- [ ] ✅ Build configuration change
- [ ] 📝 Documentation
- [ ] 🗑️ Chore

View File

@@ -1,21 +0,0 @@
{
"version": "0.2",
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
"dictionaries": ["vgv_allowed", "vgv_forbidden"],
"dictionaryDefinitions": [
{
"name": "vgv_allowed",
"path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt",
"description": "Allowed VGV Spellings"
},
{
"name": "vgv_forbidden",
"path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt",
"description": "Forbidden VGV Spellings"
}
],
"useGitignore": true,
"words": [
"flutter_blue_plus_windows"
]
}

View File

@@ -1,11 +0,0 @@
version: 2
enable-beta-ecosystems: true
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "pub"
directory: "/"
schedule:
interval: "daily"

View File

@@ -1,12 +0,0 @@
# See https://www.dartlang.org/guides/libraries/private-files
# Files and directories created by pub
.dart_tool/
.packages
build/
pubspec.lock
/.idea/
/.flutter-plugins
/.flutter-plugins-dependencies
interanl_example

View File

@@ -1,106 +0,0 @@
## 1.26.1
* Add setOptions.
## 1.26.0
* Set `flutter_blue_plus` upperbound <1.35.0 (due to api changes)
## 1.25.0
* Ensure tracing connection when reconnection occurs after force disconnection.
## 1.24.22
* Upgrade FBP version to `>=1.32.4 <=1.40.0` #24.
## 1.24.21
* fix: startScan() doesn't return correct ScanResult #25
## 1.24.20
* Downgrade FBP version to `>=1.32.4 <=1.33.6` due to the breaking changes.
* After upgrade process, the dependencies will be returned to `>=1.34.4 <1.40.0` #24.
## 1.24.19
* Fix a bug with `onValueReceived` of emitting write packet #22.
## 1.24.18
* Add implementation of `BluetoothDeviceWindow.fromId()` #21.
## 1.24.15
* Fix a bug w.r.t. company ID in manufacturer data. (@betto-a #18)
## 1.24.14
* Implement cancelOnDisconnect (@jefflongo #16)
## 1.24.12
* Fix minor bug w.r.t. `characteristic.isNotifying`.
## 1.24.11
* Fix breaking changes of FBP w.r.t. `systemDevices(List withServices)`.
## 1.24.10
* Add support for `cancelWhenScanComplete`
## 1.24.9
* Implement scan filter (including `withServices`, `withRemoteIds`, `withNames`).
## 1.24.8
* Keep manufacturer data when scanning.
## 1.24.7
* Keep service uuids when scanning.
## 1.24.0
* Update `README.md`.
## 1.23.6
* Add unimplemented notification for `read` or `write`.
## 1.14.0
* Remove dependencies `ffi` and `win32` to avoid compile error for web
## 1.9.5
* Apply `flutter blue plus` to `1.28.13`.
## 1.9.0
* Apply a breaking changes `Guid` in `Flutter blue plus` packages.
* Use `uuid128` instead of `toString()`.
## 1.8.10
* Fix `Guid` bug related with `Flutter blue plus` packages.
## 1.8.0
* Fix bug with Guid converted from string due to starting/ending with '{ }' in `WinBLE`
## 1.7.0
* Apply `flutter blue plus 1.28.5` (there is several breaking changes.).
## 1.6.6
* Add cache for storing characteristics.
## 1.6.0
* Apply `Flutter blue plus 1.26.0`, (there is a breaking change with `connect()`).
## 1.5.7
* Remove connection by OS when performing `startScan`.
## 1.5.3
* Write logs when connection state stream is started/terminated.
## 1.5.2
* Fix a bug of features added in `1.5.1`
## 1.5.1
* Remove device from connected device list when device is disconnected.
## 1.5.0
* Split functionality of `disconnect` / `removeBond`.
## 1.4.0
* Implement `Subscribe/Unsubscribe Characteristic`.
## 1.1.0
* Implement `Read/Write Characteristic`.
## 1.0.5
* Change `rxdart` version to `0.27.7`.
## 1.0.0
* Initial release (using Github action).

View File

@@ -1,7 +0,0 @@
Copyright 2023 Himchan Park
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,54 +0,0 @@
[![pub package](https://img.shields.io/pub/v/flutter_blue_plus_windows.svg)](https://pub.dartlang.org/packages/flutter_blue_plus_windows)
## Flutter Blue Plus Windows
This project is a wrapper library for `Flutter Blue Plus` and `Win_ble`.
It allows `Flutter_blue_plus` to operate on Windows.
With minimal effort, you can use Flutter Blue Plus on Windows.
## Usage
Only you need to do is change the import statement.
```dart
// instead of import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
// Alternatively, you can hide FlutterBluePlus when importing the FBP statement
import 'package:flutter_blue_plus/flutter_blue_plus.dart' hide FlutterBluePlus;
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
```
### Scan devices
```dart
final scannedDevices = <ScanResult>{};
const timeout = Duration(seconds: 3);
FlutterBluePlus.startScan(timeout: timeout);
final sub = FlutterBluePlus.scanResults.expand((e)=>e).listen(scannedDevices.add);
await Future.delayed(timeout);
sub.cancel();
scannedDevices.forEach(print);
```
### Connect a device
```dart
final scannedDevice = scannedDevices
.where((scanResult) => scanResult.device.platformName == DEVICE_NAME)
.firstOrNull;
final device = scannedDevice?.device;
device?.connect();
```
### Disconnect the device
```dart
device?.disconnect();
```
Check out the usage of Flutter Blue Plus on [Flutter Blue Plus](https://pub.dev/packages/flutter_blue_plus)

View File

@@ -1,7 +0,0 @@
library flutter_blue_plus_windows;
export 'package:flutter_blue_plus/flutter_blue_plus.dart' hide FlutterBluePlus;
export 'package:win_ble/win_ble.dart';
export 'package:win_ble/win_file.dart';
export 'src/flutter_blue_plus_windows.dart';

View File

@@ -1,19 +0,0 @@
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:win_ble/win_ble.dart';
extension BluetoothAdapterStateExtension on BleState {
BluetoothAdapterState toAdapterState() {
switch(this){
case BleState.On:
return BluetoothAdapterState.on;
case BleState.Off:
return BluetoothAdapterState.off;
case BleState.Unknown:
return BluetoothAdapterState.unknown;
case BleState.Disabled:
return BluetoothAdapterState.unavailable;
case BleState.Unsupported:
return BluetoothAdapterState.unauthorized;
}
}
}

View File

@@ -1,15 +0,0 @@
import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
extension BluetoothCharacteristicExtension on BluetoothCharacteristic {
BmBluetoothCharacteristic toProto() {
return BmBluetoothCharacteristic(
remoteId: DeviceIdentifier(remoteId.str),
serviceUuid: serviceUuid,
characteristicUuid: characteristicUuid,
descriptors: [for (final d in descriptors) d.toProto()],
properties: properties.toProto(),
primaryServiceUuid: null, // TODO: API changes
);
}
}

View File

@@ -1,14 +0,0 @@
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
extension BluetoothDescriptorExtension on BluetoothDescriptor {
BmBluetoothDescriptor toProto() {
return BmBluetoothDescriptor(
remoteId: DeviceIdentifier(remoteId.str),
serviceUuid: serviceUuid,
characteristicUuid: characteristicUuid,
descriptorUuid: descriptorUuid,
primaryServiceUuid: null, // TODO: API changes
);
}
}

View File

@@ -1,13 +0,0 @@
import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
extension BluetoothServiceExtension on BluetoothService {
BmBluetoothService toProto() {
return BmBluetoothService(
serviceUuid: serviceUuid,
remoteId: DeviceIdentifier(remoteId.str),
characteristics: [for (final c in characteristics) c.toProto()],
primaryServiceUuid: null, // TODO: API changes
);
}
}

View File

@@ -1,19 +0,0 @@
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
extension CharacteristicPropertiesExtension on CharacteristicProperties {
BmCharacteristicProperties toProto() {
return BmCharacteristicProperties(
broadcast: broadcast,
read: read,
writeWithoutResponse: writeWithoutResponse,
write: write,
notify: notify,
indicate: indicate,
authenticatedSignedWrites: authenticatedSignedWrites,
extendedProperties: extendedProperties,
notifyEncryptionRequired: notifyEncryptionRequired,
indicateEncryptionRequired: indicateEncryptionRequired,
);
}
}

View File

@@ -1,5 +0,0 @@
export 'bluetooth_adapter_state_extension.dart';
export 'bluetooth_characteristic_extension.dart';
export 'bluetooth_descriptor_extension.dart';
export 'bluetooth_service_extension.dart';
export 'characteristic_properties_extension.dart';

View File

@@ -1,3 +0,0 @@
export 'extension/extension.dart';
export 'windows/windows.dart';
export 'wrapper/wrapper.dart';

View File

@@ -1,160 +0,0 @@
part of 'windows.dart';
class BluetoothCharacteristicWindows extends BluetoothCharacteristic {
final DeviceIdentifier remoteId;
final Guid serviceUuid;
final Guid? secondaryServiceUuid;
final Guid characteristicUuid;
final List<BluetoothDescriptor> descriptors;
final Properties propertiesWinBle;
BluetoothCharacteristicWindows({
required this.remoteId,
required this.serviceUuid,
required this.characteristicUuid,
required this.descriptors,
required this.propertiesWinBle,
this.secondaryServiceUuid,
}) : super.fromProto(
BmBluetoothCharacteristic(
remoteId: DeviceIdentifier(remoteId.str),
serviceUuid: serviceUuid,
characteristicUuid: characteristicUuid,
descriptors: [
for (final descriptor in descriptors)
BmBluetoothDescriptor(
remoteId: DeviceIdentifier(descriptor.remoteId.str),
serviceUuid: descriptor.serviceUuid,
characteristicUuid: descriptor.characteristicUuid,
descriptorUuid: descriptor.uuid,
primaryServiceUuid: null, // TODO: API changes
),
],
properties: BmCharacteristicProperties(
broadcast: propertiesWinBle.broadcast ?? false,
read: propertiesWinBle.read ?? false,
writeWithoutResponse: propertiesWinBle.writeWithoutResponse ?? false,
write: propertiesWinBle.write ?? false,
notify: propertiesWinBle.notify ?? false,
indicate: propertiesWinBle.indicate ?? false,
authenticatedSignedWrites: propertiesWinBle.authenticatedSignedWrites ?? false,
// TODO: implementation missing
extendedProperties: false,
// TODO: implementation missing
notifyEncryptionRequired: false,
// TODO: implementation missing
indicateEncryptionRequired: false,
),
primaryServiceUuid: null, // TODO: API changes
),
);
String get _address => remoteId.str.toLowerCase();
String get _key => "$serviceUuid:$characteristicUuid";
FBP.BluetoothDevice get device =>
FlutterBluePlusWindows.connectedDevices.firstWhere((device) => device.remoteId == remoteId);
/// this variable is updated:
/// - anytime `read()` is called
/// - anytime `write()` is called
/// - anytime a notification arrives (if subscribed)
List<int> get lastValue => FlutterBluePlusWindows._lastChrs[remoteId]?[_key] ?? [];
/// this stream emits values:
/// - anytime `read()` is called
/// - anytime `write()` is called
/// - anytime a notification arrives (if subscribed)
/// - and when first listened to, it re-emits the last value for convenience
Stream<List<int>> get lastValueStream => _mergeStreams(
[
WinBle.characteristicValueStreamOf(
address: _address,
serviceId: serviceUuid.str128,
characteristicId: characteristicUuid.str128,
),
FlutterBluePlusWindows._charReadWriteStream.where((e) => e.$1 == _key).map((e) => e.$2)
],
).map((p) => <int>[...p]).newStreamWithInitialValue(lastValue).asBroadcastStream();
/// this stream emits values:
/// - anytime `read()` is called
/// - anytime a notification arrives (if subscribed)
Stream<List<int>> get onValueReceived => _mergeStreams(
[
WinBle.characteristicValueStreamOf(
address: _address,
serviceId: serviceUuid.str128,
characteristicId: characteristicUuid.str128,
),
FlutterBluePlusWindows._charReadStream.where((e) => e.$1 == _key).map((e) => e.$2)
],
).map((p) => <int>[...p]).asBroadcastStream();
// TODO: need to verify
bool get isNotifying => FlutterBluePlusWindows._isNotifying[remoteId]?[_key] ?? false;
Future<List<int>> read({int timeout = 15}) async {
final value = await WinBle.read(
address: _address,
serviceId: serviceUuid.str128,
characteristicId: characteristicUuid.str128,
);
FlutterBluePlusWindows._charReadWriteStreamController.add((_key, value));
FlutterBluePlusWindows._charReadStreamController.add((_key, value));
FlutterBluePlusWindows._lastChrs[remoteId] ??= {};
FlutterBluePlusWindows._lastChrs[remoteId]?[_key] = value;
return value;
}
Future<void> write(List<int> value,
{bool allowLongWrite = false, bool withoutResponse = false, int timeout = 15}) async {
await WinBle.write(
address: _address,
service: serviceUuid.str128,
characteristic: characteristicUuid.str128,
data: Uint8List.fromList(value),
writeWithResponse: !withoutResponse, // propertiesWinBle.writeWithoutResponse ?? false,
);
FlutterBluePlusWindows._charReadWriteStreamController.add((_key, value));
FlutterBluePlusWindows._lastChrs[remoteId] ??= {};
FlutterBluePlusWindows._lastChrs[remoteId]?[_key] = value;
}
// TODO: need to verify
Future<bool> setNotifyValue(
bool notify, {
int timeout = 15, // TODO: missing implementation
bool forceIndications = false, // TODO: missing implementation
}) async {
/// unSubscribeFromCharacteristic
try {
await WinBle.unSubscribeFromCharacteristic(
address: _address,
serviceId: serviceUuid.str128,
characteristicId: characteristicUuid.str128,
);
} catch (e) {
log('WinBle.unSubscribeFromCharacteristic was performed '
'before setNotifyValue()');
}
/// set notify
try {
if (notify) {
await WinBle.subscribeToCharacteristic(
address: _address,
serviceId: serviceUuid.str128,
characteristicId: characteristicUuid.str128,
);
}
FlutterBluePlusWindows._isNotifying[remoteId] ??= {};
FlutterBluePlusWindows._isNotifying[remoteId]?[_key] = notify;
} catch (e) {
log(e.toString());
}
return true;
}
}

View File

@@ -1,310 +0,0 @@
// Bluetooth Device Page:
// https://github.com/boskokg/flutter_blue_plus/blob/master/lib/src/bluetooth_device.dart
part of 'windows.dart';
class BluetoothDeviceWindows extends FBP.BluetoothDevice {
BluetoothDeviceWindows({required super.remoteId});
// used for 'servicesStream' public api
final _services = StreamController<List<BluetoothServiceWindows>>.broadcast();
// used for 'isDiscoveringServices' public api
final _isDiscoveringServices = _StreamController(initialValue: false);
String get _address => remoteId.str.toLowerCase();
/// Create a device from an id
/// - to connect, this device must have been discovered by your app in a previous scan
/// - iOS uses 128-bit uuids the remoteId, e.g. e006b3a7-ef7b-4980-a668-1f8005f84383
/// - Android uses 48-bit mac addresses as the remoteId, e.g. 06:E5:28:3B:FD:E0
static FBP.BluetoothDevice fromId(String remoteId) {
if (Platform.isWindows) {
return BluetoothDeviceWindows(remoteId: DeviceIdentifier(remoteId.toUpperCase()));
}
return FBP.BluetoothDevice.fromId(remoteId);
}
/// platform name
/// - this name is kept track of by the platform
/// - this name usually persist between app restarts
/// - iOS: after you connect, iOS uses the GAP name characteristic (0x2A00)
/// if it exists. Otherwise iOS use the advertised name.
/// - Android: always uses the advertised name
String get platformName => FlutterBluePlusWindows._platformNames[remoteId] ?? "";
/// Advertised Named
/// - this is the name advertised by the device during scanning
/// - it is only available after you scan with FlutterBluePlus
/// - it is cleared when the app restarts.
/// - not all devices advertise a name
String get advName => FlutterBluePlusWindows._advNames[remoteId] ?? "";
// stream return whether or not we are currently discovering services
@Deprecated("planed for removal (Jan 2024). It can be easily implemented yourself") // deprecated on Aug 2023
Stream<bool> get isDiscoveringServices => _isDiscoveringServices.stream;
/// Get services
/// - returns empty if discoverServices() has not been called
/// or if your device does not have any services (rare)
List<BluetoothServiceWindows> get servicesList => FlutterBluePlusWindows._knownServices[remoteId] ?? [];
/// Stream of bluetooth services offered by the remote device
/// - this stream is only updated when you call discoverServices()
@Deprecated("planed for removal (Jan 2024). It can be easily implemented yourself") // deprecated on Aug 2023
Stream<List<BluetoothService>> get servicesStream {
if (FlutterBluePlusWindows._knownServices[remoteId] != null) {
return _services.stream.newStreamWithInitialValue(
FlutterBluePlusWindows._knownServices[remoteId]!,
);
} else {
return _services.stream;
}
}
/// Register a subscription to be canceled when the device is disconnected.
/// This function simplifies cleanup, so you can prevent creating duplicate stream subscriptions.
/// - this is an optional convenience function
/// - prevents accidentally creating duplicate subscriptions on each reconnection.
/// - [next] if true, the the stream will be canceled only on the *next* disconnection.
/// This is useful if you setup your subscriptions before you connect.
/// - [delayed] Note: This option is only meant for `connectionState` subscriptions.
/// When `true`, we cancel after a small delay. This ensures the `connectionState`
/// listener receives the `disconnected` event.
void cancelWhenDisconnected(StreamSubscription subscription, {bool next = false, bool delayed = false}) {
if (isConnected == false && next == false) {
subscription.cancel(); // cancel immediately if already disconnected.
} else if (delayed) {
FlutterBluePlusWindows._delayedSubscriptions[remoteId] ??= [];
FlutterBluePlusWindows._delayedSubscriptions[remoteId]!.add(subscription);
} else {
FlutterBluePlusWindows._deviceSubscriptions[remoteId] ??= [];
FlutterBluePlusWindows._deviceSubscriptions[remoteId]!.add(subscription);
}
}
/// Returns true if this device is currently connected to your app
bool get isConnected {
return FlutterBluePlusWindows.connectedDevices.contains(this);
}
/// Returns true if this device is currently disconnected from your app
bool get isDisconnected => isConnected == false;
Future<void> connect({
Duration? timeout = const Duration(seconds: 35), // TODO: implementation missing
bool autoConnect = false, // TODO: implementation missing
int? mtu = 512, // TODO: implementation missing
}) async {
try {
await WinBle.connect(_address);
FlutterBluePlusWindows._deviceSet.add(this);
} catch (e) {
log(e.toString());
}
}
Future<void> disconnect({
int androidDelay = 2000, // TODO: implementation missing
int timeout = 35, // TODO: implementation missing
bool queue = true, // TODO: implementation missing
}) async {
try {
await WinBle.disconnect(_address);
} catch (e) {
log(e.toString());
} finally {
FlutterBluePlusWindows._deviceSet.remove(this);
FlutterBluePlusWindows._deviceSubscriptions[remoteId]?.forEach((s) => s.cancel());
FlutterBluePlusWindows._deviceSubscriptions.remove(remoteId);
// use delayed to update the stream before we cancel it
Future.delayed(Duration.zero).then((_) {
FlutterBluePlusWindows._delayedSubscriptions[remoteId]?.forEach((s) => s.cancel());
FlutterBluePlusWindows._delayedSubscriptions.remove(remoteId);
});
FlutterBluePlusWindows._lastChrs[remoteId]?.clear();
FlutterBluePlusWindows._isNotifying[remoteId]?.clear();
}
}
Future<List<BluetoothService>> discoverServices({
bool subscribeToServicesChanged = true, // TODO: implementation missing
int timeout = 15, // TODO: implementation missing
}) async {
List<BluetoothServiceWindows> result = List.from(FlutterBluePlusWindows._knownServices[remoteId] ?? []);
try {
_isDiscoveringServices.add(true);
final response = await WinBle.discoverServices(_address);
FlutterBluePlusWindows._characteristicCache[remoteId] ??= <String, List<BluetoothCharacteristic>>{};
for (final serviceId in response) {
final characteristic = await WinBle.discoverCharacteristics(
address: _address,
serviceId: serviceId,
);
FlutterBluePlusWindows._characteristicCache[remoteId] ??= {};
FlutterBluePlusWindows._characteristicCache[remoteId]?[serviceId] ??= [
...characteristic.map(
(e) => BluetoothCharacteristicWindows(
remoteId: remoteId,
serviceUuid: Guid(serviceId),
characteristicUuid: Guid(e.uuid),
descriptors: [],
// TODO: implementation missing
propertiesWinBle: e.properties,
),
),
];
}
result = [
...response.map(
(p) => BluetoothServiceWindows(
remoteId: remoteId,
serviceUuid: Guid(p),
// TODO: implementation missing
isPrimary: true,
// TODO: implementation missing
characteristics: FlutterBluePlusWindows._characteristicCache[remoteId]![p]!,
// TODO: implementation missing
includedServices: [],
),
)
];
FlutterBluePlusWindows._knownServices[remoteId] = result;
_services.add(result);
} finally {
_isDiscoveringServices.add(false);
}
return result;
}
DisconnectReason? get disconnectReason {
// TODO: nothing to do
return null;
}
Stream<BluetoothConnectionState> get connectionState async* {
await FlutterBluePlusWindows._initialize();
final map = FlutterBluePlusWindows._connectionStream.latestValue;
if (map[_address] != null) {
yield map[_address]!.isConnected;
}
yield* WinBle.connectionStreamOf(_address).map((e) => e.isConnected);
}
Stream<int> get mtu async* {
bool isEmitted = false;
int retryCount = 0;
while (!isEmitted) {
if (retryCount > 3) throw "Device not found!";
retryCount++;
try {
yield await WinBle.getMaxMtuSize(_address);
isEmitted = true;
} catch (e) {
await Future.delayed(const Duration(milliseconds: 500));
log(e.toString());
}
}
}
Future<int> readRssi({int timeout = 15}) async {
return FlutterBluePlusWindows._rssiMap[remoteId] ?? -100;
}
Future<int> requestMtu(
int desiredMtu, {
double predelay = 0.35,
int timeout = 15,
}) async {
// https://github.com/rohitsangwan01/win_ble/issues/8
return await WinBle.getMaxMtuSize(_address);
}
Future<void> requestConnectionPriority({
required ConnectionPriority connectionPriorityRequest,
}) async {
// TODO: nothing to do
return;
}
/// Set the preferred connection (Android Only)
/// - [txPhy] bitwise OR of all allowed phys for Tx, e.g. (Phy.le2m.mask | Phy.leCoded.mask)
/// - [txPhy] bitwise OR of all allowed phys for Rx, e.g. (Phy.le2m.mask | Phy.leCoded.mask)
/// - [option] preferred coding to use when transmitting on Phy.leCoded
/// Please note that this is just a recommendation given to the system.
Future<void> setPreferredPhy({
required int txPhy,
required int rxPhy,
required PhyCoding option,
}) async {
// TODO: implementation missing
}
Future<void> createBond({
Uint8List? pin,
int timeout = 90, // TODO: implementation missing
}) async {
try {
await WinBle.pair(_address);
} catch (e) {
log(e.toString());
}
}
Future<void> removeBond({
int timeout = 30, // TODO: implementation missing
}) async {
try {
await WinBle.unPair(_address);
} catch (e) {
log(e.toString());
}
}
Future<void> clearGattCache() async {
// TODO: implementation missing
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is BluetoothDeviceWindows && runtimeType == other.runtimeType && remoteId == other.remoteId);
@override
int get hashCode => remoteId.hashCode;
@override
String toString() {
return 'BluetoothDevice{'
'remoteId: $remoteId, '
'platformName: $platformName, '
'services: ${FlutterBluePlusWindows._knownServices[remoteId]}'
'}';
}
@Deprecated('Use createBond() instead')
Future<void> pair() async => await createBond();
@Deprecated('Use remoteId instead')
DeviceIdentifier get id => remoteId;
@Deprecated('Use localName instead')
String get name => localName;
@Deprecated('Use connectionState instead')
Stream<BluetoothConnectionState> get state => connectionState;
@Deprecated('Use servicesStream instead')
Stream<List<BluetoothService>> get services => servicesStream;
}

View File

@@ -1,24 +0,0 @@
part of 'windows.dart';
class BluetoothServiceWindows extends BluetoothService {
final DeviceIdentifier remoteId;
final Guid serviceUuid;
final bool isPrimary;
final List<BluetoothCharacteristic> characteristics;
final List<BluetoothService> includedServices;
BluetoothServiceWindows({
required this.remoteId,
required this.serviceUuid,
required this.isPrimary,
required this.characteristics,
required this.includedServices,
}) : super.fromProto(
BmBluetoothService(
remoteId: DeviceIdentifier(remoteId.str),
serviceUuid: serviceUuid,
characteristics: [for (final c in characteristics) c.toProto()],
primaryServiceUuid: null,
),
);
}

View File

@@ -1,353 +0,0 @@
part of 'windows.dart';
class FlutterBluePlusWindows {
static bool _initialized = false;
static BluetoothAdapterState _state = BluetoothAdapterState.unknown;
// stream used for the isScanning public api
static final _isScanning = _StreamController(initialValue: false);
// we always keep track of these device variables
static final _platformNames = <DeviceIdentifier, String>{};
static final _advNames = <DeviceIdentifier, String>{};
static final _rssiMap = <DeviceIdentifier, int?>{};
static final _knownServices = <DeviceIdentifier, List<BluetoothServiceWindows>>{};
static final Map<DeviceIdentifier, Map<String, List<int>>> _lastChrs = {};
static final Map<DeviceIdentifier, Map<String, bool>> _isNotifying = {};
static final Map<DeviceIdentifier, Map<String, List<BluetoothCharacteristic>>> _characteristicCache = {};
static final Map<DeviceIdentifier, List<StreamSubscription>> _deviceSubscriptions = {};
static final Map<DeviceIdentifier, List<StreamSubscription>> _delayedSubscriptions = {};
static final List<StreamSubscription> _scanSubscriptions = [];
// stream used for the scanResults public api
static final _scanResultsList = _StreamController(initialValue: <ScanResult>[]);
// the subscription to the scan results stream
static StreamSubscription<BleDevice?>? _scanSubscription;
// timeout for scanning that can be cancelled by stopScan
static Timer? _scanTimeout;
static List<BluetoothDeviceWindows> get _devices => [..._deviceSet];
static final _deviceSet = <BluetoothDeviceWindows>{};
static final _removedDeviceTracer = <BluetoothDeviceWindows, StreamSubscription>{};
// static final _unhandledDeviceSet = <BluetoothDeviceWindows>{};
/// Flutter blue plus windows
static final _charReadWriteStreamController = StreamController<(String, List<int>)>();
static final _charReadStreamController = StreamController<(String, List<int>)>();
static final _charReadWriteStream = _charReadWriteStreamController.stream.asBroadcastStream();
static final _charReadStream = _charReadStreamController.stream.asBroadcastStream();
/// Flutter blue plus windows
static final _connectionStream = _StreamController(initialValue: <String, bool>{});
static Future<void> _initialize() async {
if (_initialized) return;
await WinBle.initialize(
serverPath: await WinServer.path(),
enableLog: false,
);
WinBle.connectionStream.listen(
(event) {
log('$event - event');
if (event['device'] == null) return;
if (event['connected'] == null) return;
final map = _connectionStream.latestValue;
map[event['device']] = event['connected'];
log('$map - map');
_connectionStream.add(map);
if (!event['connected']) {
final removingDevices = [
..._deviceSet.where(
(device) => device._address == event['device'],
),
];
for (final device in removingDevices) {
_deviceSet.remove(device);
if (!_removedDeviceTracer.keys.contains(device)) {
_removedDeviceTracer[device] = Stream.periodic(const Duration(seconds: 10), (_) => device).listen(
(event) {
if(event.isConnected) {
_removedDeviceTracer[device]?.cancel();
_removedDeviceTracer.remove(device);
return;
}
event.connect();
},
);
}
_deviceSubscriptions[device.remoteId]?.forEach((s) => s.cancel());
_deviceSubscriptions.remove(device.remoteId);
// use delayed to update the stream before we cancel it
Future.delayed(Duration.zero).then((_) {
_delayedSubscriptions[device.remoteId]?.forEach((s) => s.cancel());
_delayedSubscriptions.remove(device.remoteId);
});
_lastChrs[device.remoteId]?.clear();
_isNotifying[device.remoteId]?.clear();
}
}
},
);
_initialized = true;
}
static Future<bool> get isSupported async {
return true;
}
static Future<String> get adapterName async {
return 'Windows';
}
static Stream<bool> get isScanning => _isScanning.stream;
static bool get isScanningNow => _isScanning.latestValue;
static Future<void> turnOn({int timeout = 10}) async {
await _initialize();
await WinBle.updateBluetoothState(true);
}
// TODO: compare with original lib
static Stream<List<ScanResult>> get scanResults => _scanResultsList.stream;
static Stream<BluetoothAdapterState> get adapterState async* {
await _initialize();
yield _state;
yield* WinBle.bleState.asBroadcastStream().map(
(s) {
_state = s.toAdapterState();
return _state;
},
);
}
/// Start a scan, and return a stream of results
/// Note: scan filters use an "or" behavior. i.e. if you set `withServices` & `withNames` we
/// return all the advertisments that match any of the specified services *or* any of the specified names.
/// - [withServices] filter by advertised services
/// - [withRemoteIds] filter for known remoteIds (iOS: 128-bit guid, android: 48-bit mac address)
/// - [withNames] filter by advertised names (exact match)
/// - [withKeywords] filter by advertised names (matches any substring)
/// - [withMsd] filter by manfacture specific data
/// - [withServiceData] filter by service data
/// - [timeout] calls stopScan after a specified duration
/// - [removeIfGone] if true, remove devices after they've stopped advertising for X duration
/// - [continuousUpdates] If `true`, we continually update 'lastSeen' & 'rssi' by processing
/// duplicate advertisements. This takes more power. You typically should not use this option.
/// - [continuousDivisor] Useful to help performance. If divisor is 3, then two-thirds of advertisements are
/// ignored, and one-third are processed. This reduces main-thread usage caused by the platform channel.
/// The scan counting is per-device so you always get the 1st advertisement from each device.
/// If divisor is 1, all advertisements are returned. This argument only matters for `continuousUpdates` mode.
/// - [oneByOne] if `true`, we will stream every advertistment one by one, possibly including duplicates.
/// If `false`, we deduplicate the advertisements, and return a list of devices.
/// - [androidLegacy] Android only. If `true`, scan on 1M phy only.
/// If `false`, scan on all supported phys. How the radio cycles through all the supported phys is purely
/// dependent on the your Bluetooth stack implementation.
/// - [androidScanMode] choose the android scan mode to use when scanning
/// - [androidUsesFineLocation] request `ACCESS_FINE_LOCATION` permission at runtime
static Future<void> startScan({
List<Guid> withServices = const [],
List<String> withRemoteIds = const [],
List<String> withNames = const [],
//TODO: implementation missing
List<String> withKeywords = const [],
//TODO: implementation missing
List<MsdFilter> withMsd = const [],
List<ServiceDataFilter> withServiceData = const [],
Duration? timeout,
Duration? removeIfGone,
bool continuousUpdates = false,
int continuousDivisor = 1,
bool oneByOne = false,
bool androidLegacy = false,
AndroidScanMode androidScanMode = AndroidScanMode.lowLatency,
bool androidUsesFineLocation = false,
}) async {
await _initialize();
// stop existing scan
if (_isScanning.latestValue == true) {
await stopScan();
}
// push to stream
_isScanning.add(true);
// Start timer *after* stream is being listened to, to make sure the
// timeout does not fire before _buffer is set
if (timeout != null) {
_scanTimeout = Timer(timeout, stopScan);
}
/// remove connection by OS.
/// The reason why we add this logic is
/// to avoid uncontrollable devices and to make consistency.
/// add WinBle scanning
WinBle.startScanning();
// check every 250ms for gone devices?
late Stream<BleDevice?> outputStream;
if (removeIfGone != null) {
outputStream = _mergeStreams([WinBle.scanStream, Stream.periodic(Duration(milliseconds: 250))]);
} else {
outputStream = WinBle.scanStream;
}
final output = <ScanResult>[];
// listen & push to `scanResults` stream
_scanSubscription = outputStream.listen(
(BleDevice? winBleDevice) {
// print(winBleDevice?.serviceUuids);
if (winBleDevice == null) {
// if null, this is just a periodic update for removing old results
output.removeWhere((elm) => DateTime.now().difference(elm.timeStamp) > removeIfGone!);
// push to stream
_scanResultsList.add(List.from(output));
} else {
final remoteId = DeviceIdentifier(winBleDevice.address.toUpperCase());
final scanResult = output.where((sr) => sr.device.remoteId == remoteId).firstOrNull;
final deviceName = winBleDevice.name.isNotEmpty ? winBleDevice.name : scanResult?.device.platformName ?? '';
final serviceUuids = winBleDevice.serviceUuids.isNotEmpty
? [...winBleDevice.serviceUuids.map((e) => Guid((e as String).replaceAll(RegExp(r'[{}]'), '')))]
: scanResult?.advertisementData.serviceUuids ?? [];
final manufacturerData = winBleDevice.manufacturerData.isNotEmpty
? {
if (winBleDevice.manufacturerData.length >= 2)
winBleDevice.manufacturerData[0] + (winBleDevice.manufacturerData[1] << 8):
winBleDevice.manufacturerData.sublist(2),
}
: scanResult?.advertisementData.manufacturerData ?? {};
final rssi = int.tryParse(winBleDevice.rssi) ?? -100;
FlutterBluePlusWindows._platformNames[remoteId] = deviceName;
FlutterBluePlusWindows._advNames[remoteId] = deviceName;
FlutterBluePlusWindows._rssiMap[remoteId] = rssi;
final device = BluetoothDeviceWindows(remoteId: remoteId);
String hex(int value) => value.toRadixString(16).padLeft(2, '0');
String hexToId(Iterable<int> values) => values.map((e) => hex(e)).join();
final sr = ScanResult(
device: device,
advertisementData: AdvertisementData(
advName: deviceName,
txPowerLevel: winBleDevice.adStructures?.where((e) => e.type == 10).singleOrNull?.data.firstOrNull,
//TODO: Should verify
connectable: !winBleDevice.advType.contains('Non'),
manufacturerData: manufacturerData,
serviceData: {
for (final advStructures in winBleDevice.adStructures ?? <AdStructure>[])
if (advStructures.type == 0x16 && advStructures.data.length >= 2)
Guid(hexToId(advStructures.data.sublist(0, 2).reversed)): advStructures.data.sublist(2),
for (final advStructures in winBleDevice.adStructures ?? <AdStructure>[])
if (advStructures.type == 0x20 && advStructures.data.length >= 4)
Guid(hexToId(advStructures.data.sublist(0, 4).reversed)): advStructures.data.sublist(4),
for (final advStructures in winBleDevice.adStructures ?? <AdStructure>[])
if (advStructures.type == 0x21 && advStructures.data.length >= 16)
Guid(hexToId(advStructures.data.sublist(0, 16).reversed)): advStructures.data.sublist(16),
},
serviceUuids: serviceUuids,
appearance: null,
),
rssi: rssi,
timeStamp: DateTime.now(),
);
// filter with services
final isFilteredWithServices =
withServices.isNotEmpty && serviceUuids.where((service) => withServices.contains(service)).isEmpty;
// filter with remote ids
final isFilteredWithRemoteIds = withRemoteIds.isNotEmpty && !withRemoteIds.contains(remoteId);
// filter with names
final isFilteredWithNames = withNames.isNotEmpty && !withNames.contains(deviceName);
if (isFilteredWithServices || isFilteredWithRemoteIds || isFilteredWithNames) {
_scanResultsList.add(List.from(output));
return;
}
// add result to output
if (oneByOne) {
output
..clear()
..add(sr);
} else {
output.addOrUpdate(sr);
}
// push to stream
_scanResultsList.add(List.from(output));
}
},
);
}
static List<FBP.BluetoothDevice> get connectedDevices {
return _devices;
}
static Future<List<BluetoothDeviceWindows>> get bondedDevices async {
return _devices;
}
/// Stops a scan for Bluetooth Low Energy devices
static Future<void> stopScan() async {
await _initialize();
WinBle.stopScanning();
_scanSubscription?.cancel();
_scanTimeout?.cancel();
_isScanning.add(false);
for (var subscription in _scanSubscriptions) {
subscription.cancel();
}
_scanResultsList.latestValue = [];
}
/// Register a subscription to be canceled when scanning is complete.
/// This function simplifies cleanup, so you can prevent creating duplicate stream subscriptions.
/// - this is an optional convenience function
/// - prevents accidentally creating duplicate subscriptions before each scan
static void cancelWhenScanComplete(StreamSubscription subscription) {
_scanSubscriptions.add(subscription);
}
/// Sets the internal FlutterBlue log level
static Future<void> setLogLevel(LogLevel level, {color = true}) async {
// Nothing to implement
return;
}
static Future<void> turnOff({int timeout = 10}) async {
await _initialize();
await WinBle.updateBluetoothState(false);
}
// TODO: need to test
static Future<bool> get isOn async {
await _initialize();
return await WinBle.bleState.asBroadcastStream().first == BleState.On;
}
}

View File

@@ -1,433 +0,0 @@
part of 'windows.dart';
String _hexEncode(List<int> numbers) {
return numbers
.map((n) => (n & 0xFF).toRadixString(16).padLeft(2, '0'))
.join();
}
List<int> _hexDecode(String hex) {
List<int> numbers = [];
for (int i = 0; i < hex.length; i += 2) {
String hexPart = hex.substring(i, i + 2);
int num = int.parse(hexPart, radix: 16);
numbers.add(num);
}
return numbers;
}
int _compareAsciiLowerCase(String a, String b) {
const int upperCaseA = 0x41;
const int upperCaseZ = 0x5a;
const int asciiCaseBit = 0x20;
var defaultResult = 0;
for (var i = 0; i < a.length; i++) {
if (i >= b.length) return 1;
var aChar = a.codeUnitAt(i);
var bChar = b.codeUnitAt(i);
if (aChar == bChar) continue;
var aLowerCase = aChar;
var bLowerCase = bChar;
// Upper case if ASCII letters.
if (upperCaseA <= bChar && bChar <= upperCaseZ) {
bLowerCase += asciiCaseBit;
}
if (upperCaseA <= aChar && aChar <= upperCaseZ) {
aLowerCase += asciiCaseBit;
}
if (aLowerCase != bLowerCase) return (aLowerCase - bLowerCase).sign;
if (defaultResult == 0) defaultResult = aChar - bChar;
}
if (b.length > a.length) return -1;
return defaultResult.sign;
}
// This is a reimplementation of BehaviorSubject from RxDart library.
// It is essentially a stream but:
// 1. we cache the latestValue of the stream
// 2. the "latestValue" is re-emitted whenever the stream is listened to
class _StreamController<T> {
T latestValue;
final StreamController<T> _controller = StreamController<T>.broadcast();
_StreamController({required T initialValue})
: this.latestValue = initialValue;
Stream<T> get stream => _controller.stream;
T get value => latestValue;
void add(T newValue) {
latestValue = newValue;
_controller.add(newValue);
}
void listen(Function(T) onData,
{Function? onError, void Function()? onDone, bool? cancelOnError}) {
onData(latestValue);
_controller.stream.listen(onData,
onError: onError, onDone: onDone, cancelOnError: cancelOnError);
}
Future<void> close() {
return _controller.close();
}
}
// imediately starts listening to a broadcast stream and
// buffering it in a new single-subscription stream
class _BufferStream<T> {
final Stream<T> _inputStream;
late final StreamSubscription? _subscription;
late final StreamController<T> _controller;
late bool hasReceivedValue = false;
_BufferStream.listen(this._inputStream) {
_controller = StreamController<T>(
onCancel: () {
_subscription?.cancel();
},
onPause: () {
_subscription?.pause();
},
onResume: () {
_subscription?.resume();
},
onListen: () {}, // inputStream is already listened to
);
// immediately start listening to the inputStream
_subscription = _inputStream.listen(
(data) {
hasReceivedValue = true;
_controller.add(data);
},
onError: (e) {
_controller.addError(e);
},
onDone: () {
_controller.close();
},
cancelOnError: false,
);
}
void close() {
_subscription?.cancel();
_controller.close();
}
Stream<T> get stream async* {
yield* _controller.stream;
}
}
// helper for 'doOnDone' method for streams.
class _OnDoneTransformer<T> extends StreamTransformerBase<T, T> {
final Function onDone;
_OnDoneTransformer({required this.onDone});
@override
Stream<T> bind(Stream<T> stream) {
if (stream.isBroadcast) {
return _bindBroadcast(stream);
}
return _bindSingleSubscription(stream);
}
Stream<T> _bindSingleSubscription(Stream<T> stream) {
StreamController<T>? controller;
StreamSubscription<T>? subscription;
controller = StreamController<T>(
onListen: () {
subscription = stream.listen(
controller?.add,
onError: controller?.addError,
onDone: () {
onDone();
controller?.close();
},
);
},
onPause: ([Future<dynamic>? resumeSignal]) {
subscription?.pause(resumeSignal);
},
onResume: () {
subscription?.resume();
},
onCancel: () {
return subscription?.cancel();
},
sync: true,
);
return controller.stream;
}
Stream<T> _bindBroadcast(Stream<T> stream) {
StreamController<T>? controller;
StreamSubscription<T>? subscription;
controller = StreamController<T>.broadcast(
onListen: () {
subscription = stream
.listen(controller?.add, onError: controller?.addError, onDone: () {
onDone();
controller?.close();
});
},
onCancel: () {
subscription?.cancel();
},
sync: true,
);
return controller.stream;
}
}
// helper for 'doOnCancel' method for streams.
class _OnCancelTransformer<T> extends StreamTransformerBase<T, T> {
final Function onCancel;
_OnCancelTransformer({required this.onCancel});
@override
Stream<T> bind(Stream<T> stream) {
if (stream.isBroadcast) {
return _bindBroadcast(stream);
}
return _bindSingleSubscription(stream);
}
Stream<T> _bindSingleSubscription(Stream<T> stream) {
StreamController<T>? controller;
StreamSubscription<T>? subscription;
controller = StreamController<T>(
onListen: () {
subscription = stream.listen(
controller?.add,
onError: (Object error) {
controller?.addError(error);
controller?.close();
},
onDone: controller?.close,
);
},
onPause: ([Future<dynamic>? resumeSignal]) {
subscription?.pause(resumeSignal);
},
onResume: () {
subscription?.resume();
},
onCancel: () {
onCancel();
return subscription?.cancel();
},
sync: true,
);
return controller.stream;
}
Stream<T> _bindBroadcast(Stream<T> stream) {
StreamController<T>? controller;
StreamSubscription<T>? subscription;
controller = StreamController<T>.broadcast(
onListen: () {
subscription = stream.listen(
controller?.add,
onError: (Object error) {
controller?.addError(error);
controller?.close();
},
onDone: controller?.close,
);
},
onCancel: () {
onCancel();
subscription?.cancel();
},
sync: true,
);
return controller.stream;
}
}
// Helper for 'newStreamWithInitialValue' method for streams.
class _NewStreamWithInitialValueTransformer<T>
extends StreamTransformerBase<T, T> {
final T initialValue;
_NewStreamWithInitialValueTransformer(this.initialValue);
@override
Stream<T> bind(Stream<T> stream) {
return _bindSingleSubscription(stream);
}
Stream<T> _bindSingleSubscription(Stream<T> stream) {
StreamController<T>? controller;
StreamSubscription<T>? subscription;
controller = StreamController<T>(
onListen: () {
// Emit the initial value
controller?.add(initialValue);
subscription = stream.listen(
controller?.add,
onError: (Object error) {
controller?.addError(error);
controller?.close();
},
onDone: controller?.close,
);
},
onPause: ([Future<dynamic>? resumeSignal]) {
subscription?.pause(resumeSignal);
},
onResume: () {
subscription?.resume();
},
onCancel: () {
return subscription?.cancel();
},
sync: true,
);
return controller.stream;
}
}
extension _StreamDoOnDone<T> on Stream<T> {
// ignore: unused_element
Stream<T> doOnDone(void Function() onDone) {
return transform(_OnDoneTransformer(onDone: onDone));
}
}
extension _StreamDoOnCancel<T> on Stream<T> {
// ignore: unused_element
Stream<T> doOnCancel(void Function() onCancel) {
return transform(_OnCancelTransformer(onCancel: onCancel));
}
}
extension _StreamNewStreamWithInitialValue<T> on Stream<T> {
Stream<T> newStreamWithInitialValue(T initialValue) {
return transform(_NewStreamWithInitialValueTransformer(initialValue));
}
}
// ignore: unused_element
Stream<T> _mergeStreams<T>(List<Stream<T>> streams) {
StreamController<T> controller = StreamController<T>();
List<StreamSubscription<T>> subscriptions = [];
void handleData(T data) {
if (!controller.isClosed) {
controller.add(data);
}
}
void handleError(Object error, StackTrace stackTrace) {
if (!controller.isClosed) {
controller.addError(error, stackTrace);
}
}
void handleDone() {
if (subscriptions.every((s) => s.isPaused)) {
controller.close();
}
}
void subscribeToStream(Stream<T> stream) {
final s =
stream.listen(handleData, onError: handleError, onDone: handleDone);
subscriptions.add(s);
}
streams.forEach(subscribeToStream);
controller.onCancel = () async {
await Future.wait(subscriptions.map((s) => s.cancel()));
};
return controller.stream;
}
// dart is single threaded, but still has task switching.
// this mutex lets a single task through at a time.
class _Mutex {
final StreamController _controller = StreamController.broadcast();
int current = 0;
int issued = 0;
Future<void> take() async {
int mine = issued;
issued++;
// tasks are executed in the same order they call take()
while (mine != current) {
await _controller.stream.first; // wait
}
}
void give() {
current++;
_controller.add(null); // release waiting tasks
}
}
// Create mutexes in a parrallel-safe way,
class _MutexFactory {
static final _Mutex _global = _Mutex();
static final Map<String, _Mutex> _all = {};
static Future<_Mutex> getMutexForKey(String key) async {
_Mutex? value;
await _global.take();
{
_all[key] ??= _Mutex();
value = _all[key];
}
_global.give();
return value!;
}
}
String _black(String s) {
// Use ANSI escape codes
return '\x1B[1;30m$s\x1B[0m';
}
// ignore: unused_element
String _green(String s) {
// Use ANSI escape codes
return '\x1B[1;32m$s\x1B[0m';
}
String _magenta(String s) {
// Use ANSI escape codes
return '\x1B[1;35m$s\x1B[0m';
}
String _brown(String s) {
// Use ANSI escape codes
return '\x1B[1;33m$s\x1B[0m';
}
extension Boolean2ConnectionState on bool {
BluetoothConnectionState get isConnected {
if (this) return BluetoothConnectionState.connected;
return BluetoothConnectionState.disconnected;
}
}

View File

@@ -1,14 +0,0 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as FBP;
import 'package:flutter_blue_plus_platform_interface/flutter_blue_plus_platform_interface.dart';
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
part 'bluetooth_characteristic_windows.dart';
part 'bluetooth_device_windows.dart';
part 'bluetooth_service_windows.dart';
part 'flutter_blue_plus_windows.dart';
part 'util.dart';

View File

@@ -1,141 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as FBP;
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
class FlutterBluePlus {
static Future<void> startScan({
List<Guid> withServices = const [],
List<String> withRemoteIds = const [],
List<String> withNames = const [],
List<String> withKeywords = const [],
List<MsdFilter> withMsd = const [],
List<ServiceDataFilter> withServiceData = const [],
Duration? timeout,
Duration? removeIfGone,
bool continuousUpdates = false,
int continuousDivisor = 1,
bool oneByOne = false,
bool androidLegacy = false,
AndroidScanMode androidScanMode = AndroidScanMode.lowLatency,
bool androidUsesFineLocation = false,
List<Guid> webOptionalServices = const [],
}) async {
if (!kIsWeb && Platform.isWindows) {
return await FlutterBluePlusWindows.startScan(
withServices: withServices,
withRemoteIds: withRemoteIds,
withNames: withNames,
withKeywords: withKeywords,
withMsd: withMsd,
withServiceData: withServiceData,
timeout: timeout,
removeIfGone: removeIfGone,
continuousUpdates: continuousUpdates,
continuousDivisor: continuousDivisor,
oneByOne: oneByOne,
androidLegacy: androidLegacy,
androidScanMode: androidScanMode,
androidUsesFineLocation: androidUsesFineLocation,
);
}
return await FBP.FlutterBluePlus.startScan(
withServices: withServices,
withRemoteIds: withRemoteIds,
withNames: withNames,
withKeywords: withKeywords,
withMsd: withMsd,
withServiceData: withServiceData,
timeout: timeout,
removeIfGone: removeIfGone,
continuousUpdates: continuousUpdates,
continuousDivisor: continuousDivisor,
oneByOne: oneByOne,
androidLegacy: androidLegacy,
androidScanMode: androidScanMode,
androidUsesFineLocation: androidUsesFineLocation,
webOptionalServices: webOptionalServices,
);
}
static Stream<BluetoothAdapterState> get adapterState {
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.adapterState;
return FBP.FlutterBluePlus.adapterState;
}
static Stream<List<ScanResult>> get scanResults {
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.scanResults;
return FBP.FlutterBluePlus.scanResults;
}
static bool get isScanningNow {
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.isScanningNow;
return FBP.FlutterBluePlus.isScanningNow;
}
static Stream<bool> get isScanning {
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.isScanning;
return FBP.FlutterBluePlus.isScanning;
}
static Future<void> stopScan() async {
if (!kIsWeb && Platform.isWindows) return await FlutterBluePlusWindows.stopScan();
return await FBP.FlutterBluePlus.stopScan();
}
static Future<void> setLogLevel(LogLevel level, {color = true}) async {
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.setLogLevel(level, color: color);
return FBP.FlutterBluePlus.setLogLevel(level, color: color);
}
/// TODO: need to verify
static LogLevel get logLevel => FBP.FlutterBluePlus.logLevel;
static Future<void> setOptions({bool restoreState = false, bool showPowerAlert = true}) async {
if (!kIsWeb && Platform.isWindows) return;
FBP.FlutterBluePlus.setOptions(restoreState: restoreState, showPowerAlert: showPowerAlert);
}
static Future<bool> get isSupported async {
if (!kIsWeb && Platform.isWindows) return await FlutterBluePlusWindows.isSupported;
return await FBP.FlutterBluePlus.isSupported;
}
static Future<String> get adapterName async {
if (!kIsWeb && Platform.isWindows) return await FlutterBluePlusWindows.adapterName;
return await FBP.FlutterBluePlus.adapterName;
}
static Future<void> turnOn({int timeout = 60}) async {
if (!kIsWeb && Platform.isWindows) return await FlutterBluePlusWindows.turnOn(timeout: timeout);
return await FBP.FlutterBluePlus.turnOn(timeout: timeout);
}
static List<FBP.BluetoothDevice> get connectedDevices {
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.connectedDevices;
return FBP.FlutterBluePlus.connectedDevices;
}
static Future<List<FBP.BluetoothDevice>> systemDevices(List<Guid> withServices) async {
//TODO: connected devices => system devices
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.connectedDevices;
return await FBP.FlutterBluePlus.systemDevices(withServices);
}
static Future<PhySupport> getPhySupport() {
return FBP.FlutterBluePlus.getPhySupport();
}
static Future<List<FBP.BluetoothDevice>> get bondedDevices async {
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.connectedDevices;
return await FBP.FlutterBluePlus.bondedDevices;
}
static void cancelWhenScanComplete(StreamSubscription subscription) {
if (!kIsWeb && Platform.isWindows) return FlutterBluePlusWindows.cancelWhenScanComplete(subscription);
return FBP.FlutterBluePlus.cancelWhenScanComplete(subscription);
}
}

View File

@@ -1 +0,0 @@
export 'flutter_blue_plus_wrapper.dart';

View File

@@ -1,13 +0,0 @@
name: flutter_blue_plus_windows
description: Flutter blue plus for Windows
version: 1.26.1
repository: https://github.com/chan150/flutter_blue_plus_windows
#publish_to: none
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
flutter_blue_plus: ">=1.32.4"
win_ble: ">=1.1.1"
stream_with_value: ">=0.5.0"

View File

@@ -1,13 +1,11 @@
import 'dart:typed_data';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
class BleUuid {
static final ZWIFT_CUSTOM_SERVICE_UUID = Guid("00000001-19CA-4651-86E5-FA29DCDD09D1");
static final ZWIFT_RIDE_CUSTOM_SERVICE_UUID = Guid("0000fc82-0000-1000-8000-00805f9b34fb");
static final ZWIFT_ASYNC_CHARACTERISTIC_UUID = Guid("00000002-19CA-4651-86E5-FA29DCDD09D1");
static final ZWIFT_SYNC_RX_CHARACTERISTIC_UUID = Guid("00000003-19CA-4651-86E5-FA29DCDD09D1");
static final ZWIFT_SYNC_TX_CHARACTERISTIC_UUID = Guid("00000004-19CA-4651-86E5-FA29DCDD09D1");
static final ZWIFT_CUSTOM_SERVICE_UUID = "00000001-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
static final ZWIFT_RIDE_CUSTOM_SERVICE_UUID = "0000fc82-0000-1000-8000-00805f9b34fb".toLowerCase();
static final ZWIFT_ASYNC_CHARACTERISTIC_UUID = "00000002-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
static final ZWIFT_SYNC_RX_CHARACTERISTIC_UUID = "00000003-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
static final ZWIFT_SYNC_TX_CHARACTERISTIC_UUID = "00000004-19CA-4651-86E5-FA29DCDD09D1".toLowerCase();
}
class Constants {
@@ -17,6 +15,10 @@ class Constants {
static const RC1_LEFT_SIDE = 0x03;
static const RC1_RIGHT_SIDE = 0x02;
// Zwift Ride
static const RIDE_RIGHT_SIDE = 0x07;
static const RIDE_LEFT_SIDE = 0x08;
// Zwift Click = BC1
static const BC1 = 0x09;
@@ -35,7 +37,7 @@ class Constants {
// not figured out the protobuf type this really is, the content is just two varints.
static const int CLICK_NOTIFICATION_MESSAGE_TYPE = 55;
static const int PLAY_NOTIFICATION_MESSAGE_TYPE = 7;
static const int RIDE_NOTIFICATION_MESSAGE_TYPE = 35;
static const int RIDE_NOTIFICATION_MESSAGE_TYPE = 35; // 0x23
// see this if connected to Core then Zwift connects to it. just one byte
static const DISCONNECT_MESSAGE_TYPE = 0xFE;
@@ -43,9 +45,10 @@ class Constants {
enum DeviceType {
click,
ride,
playLeft,
playRight;
playRight,
rideRight,
rideLeft;
@override
String toString() {
@@ -61,6 +64,10 @@ enum DeviceType {
return DeviceType.playLeft;
case Constants.RC1_RIGHT_SIDE:
return DeviceType.playRight;
case Constants.RIDE_RIGHT_SIDE:
return DeviceType.rideRight;
case Constants.RIDE_LEFT_SIDE:
return DeviceType.rideLeft;
}
return null;
}

View File

@@ -0,0 +1,137 @@
import 'dart:async';
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/requirements/android.dart';
import 'package:universal_ble/universal_ble.dart';
import '../bluetooth/ble.dart';
import 'devices/base_device.dart';
import 'messages/notification.dart';
class Connection {
final devices = <BaseDevice>[];
var androidNotificationsSetup = false;
final Map<BaseDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
Stream<BaseNotification> get actionStream => _actionStreams.stream;
final Map<BaseDevice, StreamSubscription<BleConnectionUpdate>> _connectionSubscriptions = {};
final StreamController<BaseDevice> _connectionStreams = StreamController<BaseDevice>.broadcast();
Stream<BaseDevice> get connectionStream => _connectionStreams.stream;
final _lastScanResult = <BleDevice>[];
final ValueNotifier<bool> hasDevices = ValueNotifier(false);
final ValueNotifier<bool> isScanning = ValueNotifier(false);
void initialize() {
UniversalBle.onScanResult = (result) {
if (_lastScanResult.none((e) => e.deviceId == result.deviceId)) {
_lastScanResult.add(result);
_actionStreams.add(LogNotification('Found new devices: ${result.name}'));
final scanResult = BaseDevice.fromScanResult(result);
if (scanResult != null) {
_addDevices([scanResult]);
}
}
};
UniversalBle.onValueChange = (deviceId, characteristicUuid, value) {
final device = devices.firstOrNullWhere((e) => e.device.deviceId == deviceId);
if (device == null) {
_actionStreams.add(LogNotification('Device not found: $deviceId'));
return;
} else {
device.processCharacteristic(characteristicUuid, value);
}
};
}
Future<void> performScanning() async {
isScanning.value = true;
// does not work on web, may not work on Windows
if (!kIsWeb && !Platform.isWindows) {
UniversalBle.getSystemDevices(
withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID],
).then((devices) {
final baseDevices = devices.mapNotNull(BaseDevice.fromScanResult).toList();
if (baseDevices.isNotEmpty) {
_addDevices(baseDevices);
}
});
}
await UniversalBle.startScan(
scanFilter: ScanFilter(withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID]),
platformConfig: PlatformConfig(web: WebOptions(optionalServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID])),
);
Future.delayed(Duration(seconds: 30)).then((_) {
if (isScanning.value) {
UniversalBle.stopScan();
isScanning.value = false;
}
});
}
void _addDevices(List<BaseDevice> dev) {
final newDevices = dev.where((device) => !devices.contains(device)).toList();
devices.addAll(newDevices);
for (final device in newDevices) {
_connect(device).then((_) {});
}
hasDevices.value = devices.isNotEmpty;
if (devices.isNotEmpty && !androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
androidNotificationsSetup = true;
actionHandler.init(null);
NotificationRequirement.setup().catchError((e) {
_actionStreams.add(LogNotification(e.toString()));
});
}
}
Future<void> _connect(BaseDevice bleDevice) async {
try {
final actionSubscription = bleDevice.actionStream.listen((data) {
_actionStreams.add(data);
});
final connectionStateSubscription = UniversalBle.connectionStream(bleDevice.device.deviceId).listen((
state,
) async {
bleDevice.isConnected = state.isConnected;
_connectionStreams.add(bleDevice);
});
_connectionSubscriptions[bleDevice] = connectionStateSubscription;
await bleDevice.connect();
_streamSubscriptions[bleDevice] = actionSubscription;
} catch (e, backtrace) {
_actionStreams.add(LogNotification(e.toString()));
if (kDebugMode) {
print(e);
print("backtrace: $backtrace");
}
}
}
void reset() {
UniversalBle.stopScan();
isScanning.value = false;
for (var device in devices) {
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
UniversalBle.disconnect(device.device.deviceId);
}
_lastScanResult.clear();
hasDevices.value = false;
devices.clear();
}
}

View File

@@ -0,0 +1,218 @@
import 'dart:async';
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/ble.dart';
import 'package:swift_control/bluetooth/devices/zwift_click.dart';
import 'package:swift_control/bluetooth/devices/zwift_play.dart';
import 'package:swift_control/bluetooth/devices/zwift_ride.dart';
import 'package:swift_control/utils/crypto/local_key_provider.dart';
import 'package:swift_control/utils/crypto/zap_crypto.dart';
import 'package:universal_ble/universal_ble.dart';
import '../../utils/crypto/encryption_utils.dart';
import '../messages/notification.dart';
abstract class BaseDevice {
final BleDevice scanResult;
BaseDevice(this.scanResult);
final zapEncryption = ZapCrypto(LocalKeyProvider());
bool isConnected = false;
bool supportsEncryption = true;
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_CLICK;
String get customServiceId => BleUuid.ZWIFT_CUSTOM_SERVICE_UUID;
static BaseDevice? fromScanResult(BleDevice scanResult) {
// Use the name first, probably safest method on all platforms
final device = switch (scanResult.name) {
'Zwift Ride' => ZwiftRide(scanResult),
'Zwift Play' => ZwiftPlay(scanResult),
'Zwift Click' => ZwiftClick(scanResult),
_ => null,
};
// otherwise use the manufacturer data, which doesn't exist on Web and "System Devices"
if (device == null) {
final manufacturerData = scanResult.manufacturerDataList;
final data = manufacturerData.firstOrNullWhere((e) => e.companyId == Constants.ZWIFT_MANUFACTURER_ID)?.payload;
if (data == null || data.isEmpty) {
return null;
}
final type = DeviceType.fromManufacturerData(data.first);
return switch (type) {
DeviceType.click => ZwiftClick(scanResult),
DeviceType.playRight => ZwiftPlay(scanResult),
DeviceType.playLeft => ZwiftPlay(scanResult),
DeviceType.rideRight => ZwiftRide(scanResult),
DeviceType.rideLeft => ZwiftRide(scanResult),
_ => null,
};
}
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BaseDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
@override
int get hashCode => scanResult.hashCode;
@override
String toString() {
return runtimeType.toString();
}
BleDevice get device => scanResult;
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();
Stream<BaseNotification> get actionStream => actionStreamInternal.stream;
Future<void> connect() async {
await UniversalBle.connect(device.deviceId, connectionTimeout: const Duration(seconds: 3));
if (!kIsWeb && Platform.isAndroid) {
//await UniversalBle.requestMtu(device.deviceId, 256);
}
final services = await UniversalBle.discoverServices(device.deviceId);
await _handleServices(services);
}
Future<void> _handleServices(List<BleService> services) async {
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
if (customService == null) {
throw Exception('Custom service $customServiceId not found for device $this ${device.name ?? device.rawName}');
}
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
);
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
);
final syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID,
);
if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) {
throw Exception('Characteristics not found');
}
await UniversalBle.setNotifiable(
device.deviceId,
customService.uuid,
asyncCharacteristic.uuid,
BleInputProperty.notification,
);
await UniversalBle.setNotifiable(
device.deviceId,
customService.uuid,
syncTxCharacteristic.uuid,
BleInputProperty.indication,
);
await _setupHandshake(syncRxCharacteristic);
}
Future<void> _setupHandshake(BleCharacteristic syncRxCharacteristic) async {
if (supportsEncryption) {
await UniversalBle.writeValue(
device.deviceId,
customServiceId,
syncRxCharacteristic.uuid,
Uint8List.fromList([
...Constants.RIDE_ON,
...Constants.REQUEST_START,
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
]),
BleOutputProperty.withoutResponse,
);
} else {
await UniversalBle.writeValue(
device.deviceId,
customServiceId,
syncRxCharacteristic.uuid,
Constants.RIDE_ON,
BleOutputProperty.withoutResponse,
);
}
}
void processCharacteristic(String characteristic, Uint8List bytes) {
if (kDebugMode && false) {
print('Received $characteristic: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}');
print('Received $characteristic: ${String.fromCharCodes(bytes)}');
}
if (bytes.isEmpty) {
return;
}
try {
if (bytes.startsWith(startCommand)) {
_processDevicePublicKeyResponse(bytes);
} else if (bytes.startsWith(Constants.RIDE_ON)) {
//print("Empty RideOn response - unencrypted mode");
} else if (!supportsEncryption || (bytes.length > Int32List.bytesPerElement + EncryptionUtils.MAC_LENGTH)) {
_processData(bytes);
} else if (bytes[0] == Constants.DISCONNECT_MESSAGE_TYPE) {
//print("Disconnect message");
} else {
//print("Unprocessed - Data Type: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
}
} catch (e, stackTrace) {
print("Error processing data: $e");
print("Stack Trace: $stackTrace");
actionStreamInternal.add(LogNotification(e.toString()));
}
}
void _processDevicePublicKeyResponse(Uint8List bytes) {
final devicePublicKeyBytes = bytes.sublist(Constants.RIDE_ON.length + Constants.RESPONSE_START_CLICK.length);
zapEncryption.initialise(devicePublicKeyBytes);
if (kDebugMode) {
print("Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
}
}
void _processData(Uint8List bytes) {
int type;
Uint8List message;
if (supportsEncryption) {
final counter = bytes.sublist(0, 4); // Int.SIZE_BYTES is 4
final payload = bytes.sublist(4);
final data = zapEncryption.decrypt(counter, payload);
type = data[0];
message = data.sublist(1);
} else {
type = bytes[0];
message = bytes.sublist(1);
}
switch (type) {
case Constants.EMPTY_MESSAGE_TYPE:
//print("Empty Message"); // expected when nothing happening
break;
case Constants.BATTERY_LEVEL_TYPE:
//print("Battery level update: $message");
break;
case Constants.CLICK_NOTIFICATION_MESSAGE_TYPE:
case Constants.PLAY_NOTIFICATION_MESSAGE_TYPE:
case Constants.RIDE_NOTIFICATION_MESSAGE_TYPE: // untested
processClickNotification(message);
break;
}
}
void processClickNotification(Uint8List message);
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/main.dart';
import '../messages/click_notification.dart';
class ZwiftClick extends BaseDevice {
ZwiftClick(super.scanResult);
ClickNotification? _lastClickNotification;
@override
void processClickNotification(Uint8List message) {
final ClickNotification clickNotification = ClickNotification(message);
if (_lastClickNotification == null || _lastClickNotification != clickNotification) {
_lastClickNotification = clickNotification;
actionStreamInternal.add(clickNotification);
if (clickNotification.buttonUp) {
actionHandler.increaseGear();
} else if (clickNotification.buttonDown) {
actionHandler.decreaseGear();
}
}
}
}

View File

@@ -0,0 +1,44 @@
import 'package:accessibility/accessibility.dart';
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/bluetooth/messages/play_notification.dart';
import '../../main.dart';
import '../ble.dart';
class ZwiftPlay extends BaseDevice {
ZwiftPlay(super.scanResult);
PlayNotification? _lastControllerNotification;
@override
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_PLAY;
@override
void processClickNotification(Uint8List message) {
final PlayNotification clickNotification = PlayNotification(message);
if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) {
_lastControllerNotification = clickNotification;
actionStreamInternal.add(clickNotification);
if ((clickNotification.rightPad && clickNotification.buttonShift) ||
(clickNotification.rightPad && clickNotification.analogLR.abs() == 100)) {
actionHandler.increaseGear();
} else if ((!clickNotification.rightPad && clickNotification.buttonShift) ||
(!clickNotification.rightPad && clickNotification.analogLR.abs() == 100)) {
actionHandler.decreaseGear();
}
if (clickNotification.rightPad) {
if (clickNotification.buttonA) {
actionHandler.controlMedia(MediaAction.next);
} else if (clickNotification.buttonY) {
actionHandler.controlMedia(MediaAction.volumeUp);
} else if (clickNotification.buttonB) {
actionHandler.controlMedia(MediaAction.volumeDown);
} else if (clickNotification.buttonZ) {
actionHandler.controlMedia(MediaAction.playPause);
}
}
}
}
}

View File

@@ -0,0 +1,46 @@
import 'dart:typed_data';
import 'package:swift_control/bluetooth/devices/base_device.dart';
import 'package:swift_control/bluetooth/messages/ride_notification.dart';
import 'package:swift_control/main.dart';
import '../ble.dart';
class ZwiftRide extends BaseDevice {
ZwiftRide(super.scanResult);
@override
String get customServiceId => BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID;
@override
bool get supportsEncryption => false;
RideNotification? _lastControllerNotification;
@override
void processClickNotification(Uint8List message) {
final RideNotification clickNotification = RideNotification(message);
if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) {
_lastControllerNotification = clickNotification;
actionStreamInternal.add(clickNotification);
if (clickNotification.buttonShiftDownLeft || clickNotification.buttonShiftUpLeft || clickNotification.buttonZ) {
actionHandler.decreaseGear();
} else if (clickNotification.buttonShiftUpRight ||
clickNotification.buttonShiftDownRight ||
clickNotification.buttonOnOffLeft) {
// TODO remove buttonZ once the assignment is fixed for real
actionHandler.increaseGear();
}
/*if (clickNotification.buttonA) {
actionHandler.controlMedia(MediaAction.next);
} else if (clickNotification.buttonY) {
actionHandler.controlMedia(MediaAction.volumeUp);
} else if (clickNotification.buttonB) {
actionHandler.controlMedia(MediaAction.volumeDown);
} else if (clickNotification.buttonZ) {
actionHandler.controlMedia(MediaAction.playPause);
}*/
}
}
}

View File

@@ -1,8 +1,7 @@
import 'dart:typed_data';
import 'package:swift_control/utils/messages/notification.dart';
import '../../protocol/zwift.pb.dart';
import '../protocol/zwift.pb.dart';
import 'notification.dart';
class ClickNotification extends BaseNotification {
static const int BTN_PRESSED = 0;

View File

@@ -1,16 +1,15 @@
import 'dart:typed_data';
import 'package:swift_control/utils/messages/notification.dart';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/bluetooth/protocol/zwift.pb.dart';
import '../../protocol/zwift.pb.dart';
class ControllerNotification extends BaseNotification {
class PlayNotification extends BaseNotification {
static const int BTN_PRESSED = 0;
late bool rightPad, buttonY, buttonZ, buttonA, buttonB, buttonOn, buttonShift;
late int analogLR, analogUD;
ControllerNotification(Uint8List message) {
PlayNotification(Uint8List message) {
final status = PlayKeyPadStatus.fromBuffer(message);
rightPad = status.rightPad.value == BTN_PRESSED;
@@ -41,7 +40,7 @@ class ControllerNotification extends BaseNotification {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ControllerNotification &&
other is PlayNotification &&
runtimeType == other.runtimeType &&
rightPad == other.rightPad &&
buttonY == other.buttonY &&

View File

@@ -0,0 +1,140 @@
import 'dart:typed_data';
import 'package:swift_control/bluetooth/messages/notification.dart';
import 'package:swift_control/bluetooth/protocol/zwift.pb.dart';
enum _RideButtonMask {
LEFT_BTN(0x00001),
UP_BTN(0x00002),
RIGHT_BTN(0x00004),
DOWN_BTN(0x00008),
A_BTN(0x00010),
B_BTN(0x00020),
Y_BTN(0x00040),
Z_BTN(0x00080),
SHFT_UP_L_BTN(0x00100),
SHFT_DN_L_BTN(0x00200),
SHFT_UP_R_BTN(0x01000),
SHFT_DN_R_BTN(0x02000),
POWERUP_L_BTN(0x00400),
POWERUP_R_BTN(0x04000),
ONOFF_L_BTN(0x00800),
ONOFF_R_BTN(0x08000);
final int mask;
const _RideButtonMask(this.mask);
}
class RideNotification extends BaseNotification {
static const int BTN_PRESSED = 0;
late bool buttonLeft, buttonRight, buttonUp, buttonDown;
late bool buttonA, buttonB, buttonY, buttonZ;
late bool buttonShiftUpLeft, buttonShiftDownLeft;
late bool buttonShiftUpRight, buttonShiftDownRight;
late bool buttonPowerUpLeft, buttonPowerDownLeft;
late bool buttonOnOffLeft, buttonOnOffRight;
int analogLR = 0, analogUD = 0;
RideNotification(Uint8List message) {
final status = RideKeyPadStatus.fromBuffer(message);
buttonLeft = status.buttonMap & _RideButtonMask.LEFT_BTN.mask == BTN_PRESSED;
buttonRight = status.buttonMap & _RideButtonMask.RIGHT_BTN.mask == BTN_PRESSED;
buttonUp = status.buttonMap & _RideButtonMask.UP_BTN.mask == BTN_PRESSED;
buttonDown = status.buttonMap & _RideButtonMask.DOWN_BTN.mask == BTN_PRESSED;
buttonA = status.buttonMap & _RideButtonMask.A_BTN.mask == BTN_PRESSED;
buttonB = status.buttonMap & _RideButtonMask.B_BTN.mask == BTN_PRESSED;
buttonY = status.buttonMap & _RideButtonMask.Y_BTN.mask == BTN_PRESSED;
buttonZ = status.buttonMap & _RideButtonMask.Z_BTN.mask == BTN_PRESSED;
buttonShiftUpLeft = status.buttonMap & _RideButtonMask.SHFT_UP_L_BTN.mask == BTN_PRESSED;
buttonShiftDownLeft = status.buttonMap & _RideButtonMask.SHFT_DN_L_BTN.mask == BTN_PRESSED;
buttonShiftUpRight = status.buttonMap & _RideButtonMask.SHFT_UP_R_BTN.mask == BTN_PRESSED;
buttonShiftDownRight = status.buttonMap & _RideButtonMask.SHFT_DN_R_BTN.mask == BTN_PRESSED;
buttonPowerUpLeft = status.buttonMap & _RideButtonMask.POWERUP_L_BTN.mask == BTN_PRESSED;
buttonPowerDownLeft = status.buttonMap & _RideButtonMask.POWERUP_R_BTN.mask == BTN_PRESSED;
buttonOnOffLeft = status.buttonMap & _RideButtonMask.ONOFF_L_BTN.mask == BTN_PRESSED;
buttonOnOffRight = status.buttonMap & _RideButtonMask.ONOFF_R_BTN.mask == BTN_PRESSED;
for (final analogue in status.analogButtons.groupStatus) {
if (analogue.location == RideAnalogLocation.LEFT || analogue.location == RideAnalogLocation.RIGHT) {
analogLR = analogue.analogValue;
} else if (analogue.location == RideAnalogLocation.DOWN || analogue.location == RideAnalogLocation.UP) {
analogUD = analogue.analogValue;
}
}
}
@override
String toString() {
final allTrueParameters = [
if (buttonLeft) 'buttonLeft',
if (buttonRight) 'buttonRight',
if (buttonUp) 'buttonUp',
if (buttonDown) 'buttonDown',
if (buttonA) 'buttonA',
if (buttonB) 'buttonB',
if (buttonY) 'buttonY',
if (buttonZ) 'buttonZ',
if (buttonShiftUpLeft) 'buttonShiftUpLeft',
if (buttonShiftDownLeft) 'buttonShiftDownLeft',
if (buttonShiftUpRight) 'buttonShiftUpRight',
if (buttonShiftDownRight) 'buttonShiftDownRight',
if (buttonPowerUpLeft) 'buttonPowerUpLeft',
if (buttonPowerDownLeft) 'buttonPowerDownLeft',
if (buttonOnOffLeft) 'buttonOnOffLeft',
if (buttonOnOffRight) 'buttonOnOffRight',
];
return '{$allTrueParameters, analogLR: $analogLR, analogUD: $analogUD}';
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RideNotification &&
runtimeType == other.runtimeType &&
buttonLeft == other.buttonLeft &&
buttonRight == other.buttonRight &&
buttonUp == other.buttonUp &&
buttonDown == other.buttonDown &&
buttonA == other.buttonA &&
buttonB == other.buttonB &&
buttonY == other.buttonY &&
buttonZ == other.buttonZ &&
buttonShiftUpLeft == other.buttonShiftUpLeft &&
buttonShiftDownLeft == other.buttonShiftDownLeft &&
buttonShiftUpRight == other.buttonShiftUpRight &&
buttonShiftDownRight == other.buttonShiftDownRight &&
buttonPowerUpLeft == other.buttonPowerUpLeft &&
buttonPowerDownLeft == other.buttonPowerDownLeft &&
buttonOnOffLeft == other.buttonOnOffLeft &&
buttonOnOffRight == other.buttonOnOffRight &&
analogLR == other.analogLR &&
analogUD == other.analogUD;
@override
int get hashCode =>
buttonLeft.hashCode ^
buttonRight.hashCode ^
buttonUp.hashCode ^
buttonDown.hashCode ^
buttonA.hashCode ^
buttonB.hashCode ^
buttonY.hashCode ^
buttonZ.hashCode ^
buttonShiftUpLeft.hashCode ^
buttonShiftDownLeft.hashCode ^
buttonShiftUpRight.hashCode ^
buttonShiftDownRight.hashCode ^
buttonPowerUpLeft.hashCode ^
buttonPowerDownLeft.hashCode ^
buttonOnOffLeft.hashCode ^
buttonOnOffRight.hashCode ^
analogLR.hashCode ^
analogUD.hashCode;
}

View File

@@ -1,18 +1,33 @@
import 'dart:io';
import 'package:accessibility/accessibility.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:swift_control/pages/requirements.dart';
import 'package:swift_control/theme.dart';
import 'package:swift_control/utils/connection.dart';
import 'package:swift_control/utils/actions/android.dart';
import 'package:swift_control/utils/actions/desktop.dart';
import 'package:swift_control/utils/settings/settings.dart';
import 'bluetooth/connection.dart';
import 'utils/actions/base_actions.dart';
final connection = Connection();
final actionHandler = ActionHandler();
late final BaseActions actionHandler;
final accessibilityHandler = Accessibility();
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
final settings = Settings();
void main() {
if (kIsWeb) {
actionHandler = StubActions();
} else if (Platform.isAndroid) {
actionHandler = AndroidActions();
} else {
actionHandler = DesktopActions();
}
runApp(const SwiftPlayApp());
}
@@ -22,6 +37,7 @@ class SwiftPlayApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'SwiftControl',
theme: AppTheme.light,
darkTheme: AppTheme.dark,

View File

@@ -1,11 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/devices/ble_device.dart';
import 'package:swift_control/pages/touch_area.dart';
import 'package:swift_control/widgets/logviewer.dart';
import '../utils/messages/notification.dart';
import '../bluetooth/devices/base_device.dart';
import '../widgets/menu.dart';
class DevicePage extends StatefulWidget {
const DevicePage({super.key});
@@ -15,24 +19,12 @@ class DevicePage extends StatefulWidget {
}
class _DevicePageState extends State<DevicePage> {
List<String> _actions = [];
late StreamSubscription<BleDevice> _connectionStateSubscription;
late StreamSubscription<BaseNotification> _actionSubscription;
late StreamSubscription<BaseDevice> _connectionStateSubscription;
@override
void initState() {
super.initState();
_actionSubscription = connection.actionStream.listen((data) {
if (mounted) {
setState(() {
_actions.add('${DateTime.now().toString().split(" ").last}: $data');
_actions = _actions.takeLast(30).toList();
});
}
});
_connectionStateSubscription = connection.connectionStream.listen((state) async {
setState(() {});
});
@@ -41,7 +33,6 @@ class _DevicePageState extends State<DevicePage> {
@override
void dispose() {
_connectionStateSubscription.cancel();
_actionSubscription.cancel();
super.dispose();
}
@@ -58,41 +49,51 @@ class _DevicePageState extends State<DevicePage> {
child: Scaffold(
appBar: AppBar(
title: Text('SwiftControl'),
actions: [
IconButton(
onPressed: () {
_actions.clear();
setState(() {});
},
icon: Icon(Icons.clear),
),
],
actions: buildMenuButtons(),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 10,
children: [
Text(
'Devices:\n${connection.devices.joinToString(separator: '\n', transform: (it) {
return "${it.device.platformName}: ${it.device.isConnected ? 'Connected' : 'Not connected'}";
return "${it.device.name}: ${it.isConnected ? 'Connected' : 'Not connected'}";
})}',
),
Divider(color: Theme.of(context).colorScheme.primary, height: 30),
Expanded(
child: ListView(
children:
_actions
.map(
(action) => Text(
action,
style: TextStyle(fontSize: 12, fontFeatures: [FontFeature.tabularFigures()]),
if (!kIsWeb && (Platform.isAndroid || kDebugMode)) ...[
ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder:
(_) => TouchAreaSetupPage(
onSave: (gearUp, gearDown) {
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
final convertedGearUp =
gearUp.translate(touchAreaSize / 2, touchAreaSize / 2) * devicePixelRatio;
final convertedGearDown =
gearDown.translate(touchAreaSize / 2, touchAreaSize / 2) * devicePixelRatio;
print("Gear Up Position: $gearUp - converted: $convertedGearUp");
print("Gear Down Position: $gearDown - converted: $convertedGearDown");
actionHandler.updateTouchPositions(convertedGearUp, convertedGearDown);
settings.updateTouchPositions(convertedGearUp, convertedGearDown);
},
),
)
.toList(),
),
);
},
child: Text('Customize touch areas (optional)'),
),
),
],
Expanded(child: LogViewer()),
],
),
),

View File

@@ -4,7 +4,9 @@ import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/menu.dart';
import 'device.dart';
@@ -27,14 +29,16 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
// call after first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!kIsWeb && Platform.isMacOS) {
// add more delay due tu CBManagerStateUnknown
Future.delayed(const Duration(seconds: 2), () {
settings.init().then((_) {
if (!kIsWeb && Platform.isMacOS) {
// add more delay due tu CBManagerStateUnknown
Future.delayed(const Duration(seconds: 2), () {
_reloadRequirements();
});
} else {
_reloadRequirements();
});
} else {
_reloadRequirements();
}
}
});
});
connection.hasDevices.addListener(() {
@@ -60,7 +64,11 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('SwiftControl'), backgroundColor: Theme.of(context).colorScheme.inversePrimary),
appBar: AppBar(
title: Text('SwiftControl'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: buildMenuButtons(),
),
body:
_requirements.isEmpty
? Center(child: CircularProgressIndicator())
@@ -78,7 +86,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
}
: null,
onStepTapped: (step) {
if (_requirements[step].status) {
if (_requirements[step].status && _requirements[step] is! KeymapRequirement) {
return;
}
final hasEarlierIncomplete = _requirements.indexWhere((req) => !req.status) < step;
@@ -95,16 +103,20 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
.mapIndexed(
(index, req) => Step(
title: Text(req.name),
content:
(index == _currentStep
? req.build(context, () {
_reloadRequirements();
})
: null) ??
ElevatedButton(
onPressed: req.status ? null : () => _callRequirement(req),
child: Text(req.name),
),
content: Container(
padding: const EdgeInsets.symmetric(vertical: 16.0),
alignment: Alignment.centerLeft,
child:
(index == _currentStep
? req.build(context, () {
_reloadRequirements();
})
: null) ??
ElevatedButton(
onPressed: req.status ? null : () => _callRequirement(req),
child: Text(req.name),
),
),
state: req.status ? StepState.complete : StepState.indexed,
),
)

View File

@@ -1,12 +1,10 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus_windows/flutter_blue_plus_windows.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/ble.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import '../widgets/logviewer.dart';
class ScanWidget extends StatefulWidget {
const ScanWidget({super.key});
@@ -15,73 +13,28 @@ class ScanWidget extends StatefulWidget {
}
class _ScanWidgetState extends State<ScanWidget> {
bool _isScanning = false;
late StreamSubscription<bool> _isScanningSubscription;
@override
void initState() {
super.initState();
connection.startScanning();
connection.initialize();
_isScanningSubscription = FlutterBluePlus.isScanning.listen((state) {
/*_isScanningSubscription = FlutterBluePlus.isScanning.listen((state) {
_isScanning = state;
if (mounted) {
setState(() {});
}
});
});*/
// after the first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
onScanPressed();
// must be called from a button
if (!kIsWeb) {
connection.performScanning();
}
});
}
@override
void dispose() {
_isScanningSubscription.cancel();
super.dispose();
}
Future onScanPressed() async {
try {
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
withServices: [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID, BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID],
webOptionalServices: kIsWeb ? [BleUuid.ZWIFT_CUSTOM_SERVICE_UUID] : [],
);
} catch (e, backtrace) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e'), duration: const Duration(seconds: 5)));
print(e);
print("backtrace: $backtrace");
}
if (mounted) {
setState(() {});
}
}
Future onStopPressed() async {
try {
FlutterBluePlus.stopScan();
} catch (e, backtrace) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e'), duration: const Duration(seconds: 5)));
print(e);
print("backtrace: $backtrace");
}
}
Widget buildScanButton(BuildContext context) {
if (FlutterBluePlus.isScanningNow) {
return ElevatedButton(onPressed: onStopPressed, child: const Icon(Icons.stop));
} else {
return ElevatedButton(onPressed: onScanPressed, child: const Text("SCAN"));
}
}
@override
Widget build(BuildContext context) {
return Container(
@@ -89,7 +42,36 @@ class _ScanWidgetState extends State<ScanWidget> {
child: ListView(
padding: EdgeInsets.all(16),
shrinkWrap: true,
children: [if (_isScanning) SmallProgressIndicator() else buildScanButton(context)],
children: [
ValueListenableBuilder(
valueListenable: connection.isScanning,
builder: (context, isScanning, widget) {
if (isScanning) {
return Column(
spacing: 12,
children: [
Text(
'Scanning for devices... Make sure they are powered on and in range and not connected to another device.',
),
SmallProgressIndicator(),
],
);
} else {
return Row(
children: [
ElevatedButton(
onPressed: () {
connection.performScanning();
},
child: const Text("SCAN"),
),
],
);
}
},
),
if (kDebugMode) LogViewer(),
],
),
);
}

174
lib/pages/touch_area.dart Normal file
View File

@@ -0,0 +1,174 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:swift_control/main.dart';
final touchAreaSize = 32.0;
class TouchAreaSetupPage extends StatefulWidget {
final void Function(Offset gearUp, Offset gearDown) onSave;
const TouchAreaSetupPage({required this.onSave, super.key});
@override
State<TouchAreaSetupPage> createState() => _TouchAreaSetupPageState();
}
class _TouchAreaSetupPageState extends State<TouchAreaSetupPage> {
File? _backgroundImage;
Offset _gearUpPos = const Offset(200, 300);
Offset _gearDownPos = const Offset(100, 300);
Future<void> _pickScreenshot() async {
final picker = ImagePicker();
final result = await picker.pickImage(source: ImageSource.gallery);
if (result != null) {
setState(() {
_backgroundImage = File(result.path);
});
}
}
void _saveAndClose() {
widget.onSave(_gearUpPos, _gearDownPos);
Navigator.of(context).pop();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
if (actionHandler.gearUpTouchPosition != null) {
_gearUpPos = actionHandler.gearUpTouchPosition!;
_gearUpPos = Offset(
_gearUpPos.dx / devicePixelRatio - touchAreaSize / 2,
_gearUpPos.dy / devicePixelRatio - touchAreaSize / 2,
);
}
if (actionHandler.gearDownTouchPosition != null) {
_gearDownPos = actionHandler.gearDownTouchPosition!;
_gearDownPos = Offset(
_gearDownPos.dx / devicePixelRatio - touchAreaSize / 2,
_gearDownPos.dy / devicePixelRatio - touchAreaSize / 2,
);
}
setState(() {});
});
}
Widget _buildDraggableArea({
required Offset position,
required void Function(Offset newPosition) onPositionChanged,
required Color color,
required String label,
}) {
return Positioned(
left: position.dx,
top: position.dy,
child: Draggable(
feedback: Material(color: Colors.transparent, child: _TouchDot(color: Colors.yellow, label: label)),
childWhenDragging: const SizedBox.shrink(),
onDraggableCanceled: (_, offset) {
setState(() => onPositionChanged(offset));
},
child: _TouchDot(color: color, label: label),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
if (_backgroundImage != null)
Positioned.fill(child: Opacity(opacity: 0.5, child: Image.file(_backgroundImage!, fit: BoxFit.cover)))
else
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Text('''1. Create an in-game screenshot of your app (e.g. within MyWhoosh)
2. Load the screenshot with the button below
3. Make sure the app is in the correct orientation (portrait or landscape)
4. Drag the touch areas to the correct position where the gear up / down buttons are located
5. Save and close this screen'''),
ElevatedButton(
onPressed: () {
_pickScreenshot();
},
child: Text('Load in-game screenshot for placement'),
),
],
),
),
// Touch Areas
_buildDraggableArea(
position: _gearUpPos,
onPositionChanged: (newPos) => _gearUpPos = newPos,
color: Colors.green,
label: "Gear ↑",
),
_buildDraggableArea(
position: _gearDownPos,
onPositionChanged: (newPos) => _gearDownPos = newPos,
color: Colors.red,
label: "Gear ↓",
),
Positioned(
top: 40,
right: 170,
child: ElevatedButton.icon(
onPressed: () {
_gearDownPos = Offset(100, 300);
_gearUpPos = Offset(200, 300);
setState(() {});
},
label: const Icon(Icons.lock_reset),
),
),
Positioned(
top: 40,
right: 20,
child: ElevatedButton.icon(
onPressed: _saveAndClose,
icon: const Icon(Icons.save),
label: const Text("Save & Close"),
),
),
],
),
);
}
}
class _TouchDot extends StatelessWidget {
final Color color;
final String label;
const _TouchDot({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: touchAreaSize,
height: touchAreaSize,
decoration: BoxDecoration(
color: color.withOpacity(0.6),
shape: BoxShape.circle,
border: Border.all(color: Colors.black, width: 2),
),
),
Text(label, style: TextStyle(color: Colors.black, fontSize: 12)),
],
);
}
}

View File

@@ -8,21 +8,33 @@ import 'package:swift_control/utils/keymap/keymap.dart';
class AndroidActions extends BaseActions {
static const MYWHOOSH_APP_PACKAGE = "com.mywhoosh.whooshgame";
static const TRAININGPEAKS_APP_PACKAGE = "com.indieVelo.client";
static const validPackageNames = [MYWHOOSH_APP_PACKAGE, TRAININGPEAKS_APP_PACKAGE];
WindowEvent? windowInfo;
Offset? _gearUpTouchPosition;
Offset? _gearDownTouchPosition;
@override
Offset? get gearUpTouchPosition => _gearUpTouchPosition;
@override
Offset? get gearDownTouchPosition => _gearDownTouchPosition;
@override
void init(Keymap? keymap) {
streamEvents().listen((windowEvent) {
windowInfo = windowEvent;
if (validPackageNames.contains(windowEvent.packageName)) {
windowInfo = windowEvent;
}
});
}
@override
void decreaseGear() {
if (windowInfo == null) {
throw Exception("Decrease gear: No window info");
} else {
if (_gearDownTouchPosition == null) {
if (windowInfo == null) {
throw Exception("Increasing gear: No window info");
}
final point = switch (windowInfo!.packageName) {
MYWHOOSH_APP_PACKAGE => Offset(windowInfo!.windowWidth * 0.80, windowInfo!.windowHeight * 0.94),
TRAININGPEAKS_APP_PACKAGE => Offset(windowInfo!.windowWidth / 2 * 1.15, windowInfo!.windowHeight * 0.74),
@@ -30,14 +42,17 @@ class AndroidActions extends BaseActions {
};
accessibilityHandler.performTouch(point.dx, point.dy);
} else {
accessibilityHandler.performTouch(_gearDownTouchPosition!.dx, _gearDownTouchPosition!.dy);
}
}
@override
void increaseGear() {
if (windowInfo == null) {
throw Exception("Increasing gear: No window info");
} else {
if (_gearUpTouchPosition == null) {
if (windowInfo == null) {
throw Exception("Increasing gear: No window info");
}
final point = switch (windowInfo!.packageName) {
MYWHOOSH_APP_PACKAGE => Offset(windowInfo!.windowWidth * 0.98, windowInfo!.windowHeight * 0.94),
TRAININGPEAKS_APP_PACKAGE => Offset(windowInfo!.windowWidth / 2 * 1.32, windowInfo!.windowHeight * 0.74),
@@ -45,6 +60,19 @@ class AndroidActions extends BaseActions {
};
accessibilityHandler.performTouch(point.dx, point.dy);
} else {
accessibilityHandler.performTouch(_gearUpTouchPosition!.dx, _gearUpTouchPosition!.dy);
}
}
@override
void controlMedia(MediaAction action) {
accessibilityHandler.controlMedia(action);
}
@override
void updateTouchPositions(Offset gearUp, Offset gearDown) {
_gearUpTouchPosition = gearUp;
_gearDownTouchPosition = gearDown;
}
}

View File

@@ -1,17 +1,23 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:accessibility/accessibility.dart';
import '../keymap/keymap.dart';
import 'android.dart';
import 'desktop.dart';
abstract class BaseActions {
Keymap? get keymap => null;
Offset? get gearUpTouchPosition => null;
Offset? get gearDownTouchPosition => null;
void init(Keymap? keymap) {}
void increaseGear();
void decreaseGear();
void controlMedia(MediaAction action) {
throw UnimplementedError();
}
void updateTouchPositions(Offset gearUp, Offset gearDown) {}
}
class StubActions extends BaseActions {
@@ -25,31 +31,3 @@ class StubActions extends BaseActions {
print('Increase gear');
}
}
class ActionHandler {
late BaseActions actions;
ActionHandler() {
if (kIsWeb) {
actions = StubActions();
} else if (Platform.isAndroid) {
actions = AndroidActions();
} else {
actions = DesktopActions();
}
}
Keymap? get keymap => actions.keymap;
void init(Keymap? keymap) {
actions.init(keymap);
}
void increaseGear() {
actions.increaseGear();
}
void decreaseGear() {
actions.decreaseGear();
}
}

View File

@@ -15,18 +15,20 @@ class DesktopActions extends BaseActions {
}
@override
void decreaseGear() {
Future<void> decreaseGear() async {
if (keymap == null) {
throw Exception('Keymap is not set');
}
keyPressSimulator.simulateKeyDown(_keymap!.decrease);
await keyPressSimulator.simulateKeyDown(_keymap!.decrease?.physicalKey);
await keyPressSimulator.simulateKeyUp(_keymap!.decrease?.physicalKey);
}
@override
void increaseGear() {
Future<void> increaseGear() async {
if (keymap == null) {
throw Exception('Keymap is not set');
}
keyPressSimulator.simulateKeyDown(_keymap!.increase);
await keyPressSimulator.simulateKeyDown(_keymap!.increase?.physicalKey);
await keyPressSimulator.simulateKeyUp(_keymap!.increase?.physicalKey);
}
}

View File

@@ -1,90 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/devices/ble_device.dart';
import 'package:swift_control/utils/requirements/android.dart';
import 'messages/notification.dart';
class Connection {
final devices = <BleDevice>[];
var androidNotificationsSetup = false;
final Map<BleDevice, StreamSubscription<BaseNotification>> _streamSubscriptions = {};
final StreamController<BaseNotification> _actionStreams = StreamController<BaseNotification>.broadcast();
Stream<BaseNotification> get actionStream => _actionStreams.stream;
final Map<BleDevice, StreamSubscription<BluetoothConnectionState>> _connectionSubscriptions = {};
final StreamController<BleDevice> _connectionStreams = StreamController<BleDevice>.broadcast();
Stream<BleDevice> get connectionStream => _connectionStreams.stream;
final ValueNotifier<bool> hasDevices = ValueNotifier(false);
late StreamSubscription<List<ScanResult>> _scanResultsSubscription;
void startScanning() {
_scanResultsSubscription = FlutterBluePlus.scanResults.listen(
(results) {
final scanResults = results.mapNotNull(BleDevice.fromScanResult).toList();
_addDevices(scanResults);
},
onError: (e) {
_actionStreams.add(LogNotification(e.toString()));
},
);
}
void _addDevices(List<BleDevice> dev) {
final newDevices = dev.where((device) => !devices.contains(device)).toList();
devices.addAll(newDevices);
for (final device in newDevices) {
_connect(device).then((_) {});
}
hasDevices.value = devices.isNotEmpty;
if (devices.isNotEmpty && !androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
androidNotificationsSetup = true;
actionHandler.init(null);
NotificationRequirement.setup().catchError((e) {
_actionStreams.add(LogNotification(e.toString()));
});
}
}
Future<void> _connect(BleDevice bleDevice) async {
try {
await bleDevice.connect();
final actionSubscription = bleDevice.actionStream.listen((data) {
_actionStreams.add(data);
});
_streamSubscriptions[bleDevice] = actionSubscription;
final connectionStateSubscription = bleDevice.device.connectionState.listen((state) async {
_connectionStreams.add(bleDevice);
});
_connectionSubscriptions[bleDevice] = connectionStateSubscription;
} catch (e, backtrace) {
if (e is FlutterBluePlusException && e.code == FbpErrorCode.connectionCanceled.index) {
// ignore connections canceled by the user
} else {
_actionStreams.add(LogNotification(e.toString()));
if (kDebugMode) {
print(e);
print("backtrace: $backtrace");
}
}
}
}
void reset() {
FlutterBluePlus.stopScan();
for (var device in devices) {
device.device.disconnect();
}
devices.clear();
}
}

View File

@@ -1,93 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:swift_control/utils/ble.dart';
import 'package:swift_control/utils/crypto/local_key_provider.dart';
import 'package:swift_control/utils/devices/zwift_click.dart';
import 'package:swift_control/utils/devices/zwift_play.dart';
import 'package:swift_control/utils/devices/zwift_ride.dart';
import '../crypto/zap_crypto.dart';
import '../messages/notification.dart';
abstract class BleDevice {
final ScanResult scanResult;
final zapEncryption = ZapCrypto(LocalKeyProvider());
bool supportsEncryption = true;
BleDevice(this.scanResult);
static BleDevice? fromScanResult(ScanResult scanResult) {
if (scanResult.device.platformName == 'Zwift Ride') {
return ZwiftRide(scanResult);
}
if (kIsWeb) {
// manufacturer data is not available on web
if (scanResult.device.platformName == 'Zwift Play') {
return ZwiftPlay(scanResult);
} else if (scanResult.device.platformName == 'Zwift Click') {
return ZwiftClick(scanResult);
}
}
final manufacturerData = scanResult.advertisementData.manufacturerData;
final data = manufacturerData[Constants.ZWIFT_MANUFACTURER_ID];
if (data == null || data.isEmpty) {
return null;
}
final type = DeviceType.fromManufacturerData(data.first);
return switch (type) {
DeviceType.click => ZwiftClick(scanResult),
DeviceType.playRight => ZwiftPlay(scanResult),
DeviceType.playLeft => ZwiftPlay(scanResult),
_ => null,
};
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BleDevice && runtimeType == other.runtimeType && scanResult == other.scanResult;
@override
int get hashCode => scanResult.hashCode;
@override
String toString() {
return runtimeType.toString();
}
BluetoothDevice get device => scanResult.device;
final StreamController<BaseNotification> actionStreamInternal = StreamController<BaseNotification>.broadcast();
Stream<BaseNotification> get actionStream => actionStreamInternal.stream;
Future<void> connect() async {
await device.connect(autoConnect: false).timeout(const Duration(seconds: 3));
var filteredStateStream = device.connectionState.where((s) => s == BluetoothConnectionState.connected);
// Start listening now, before invokeMethod, to ensure we don't miss the response
Future<BluetoothConnectionState> futureState = filteredStateStream.first;
// wait for connection
await futureState.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('Failed to connect in time.');
},
);
if (!kIsWeb && Platform.isAndroid) {
await device.requestMtu(256);
}
final services = await device.discoverServices();
if (device.isConnected) {
await handleServices(services);
}
}
Future<void> handleServices(List<BluetoothService> services);
}

View File

@@ -1,156 +0,0 @@
import 'dart:io';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/devices/ble_device.dart';
import 'package:swift_control/utils/messages/notification.dart';
import '../ble.dart';
import '../crypto/encryption_utils.dart';
import '../messages/click_notification.dart';
class ZwiftClick extends BleDevice {
ZwiftClick(super.scanResult);
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_CLICK;
Guid get customServiceId => BleUuid.ZWIFT_CUSTOM_SERVICE_UUID;
@override
Future<void> handleServices(List<BluetoothService> services) async {
final customService = services.firstOrNullWhere((service) => service.uuid == customServiceId);
if (customService == null) {
throw Exception('Custom service not found');
}
final asyncCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_ASYNC_CHARACTERISTIC_UUID,
);
final syncTxCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
);
final syncRxCharacteristic = customService.characteristics.firstOrNullWhere(
(characteristic) => characteristic.uuid == BleUuid.ZWIFT_SYNC_RX_CHARACTERISTIC_UUID,
);
if (asyncCharacteristic == null || syncTxCharacteristic == null || syncRxCharacteristic == null) {
throw Exception('Characteristics not found');
}
if (!asyncCharacteristic.isNotifying) {
await asyncCharacteristic.setNotifyValue(true);
}
final asyncSubscription = asyncCharacteristic.lastValueStream.listen((onData) {
_processCharacteristic('async', Uint8List.fromList(onData));
});
device.cancelWhenDisconnected(asyncSubscription);
if (!syncTxCharacteristic.isNotifying) {
await syncTxCharacteristic.setNotifyValue(true, forceIndications: !kIsWeb && Platform.isAndroid);
}
final syncSubscription = syncTxCharacteristic.lastValueStream.listen((onData) {
_processCharacteristic('sync', Uint8List.fromList(onData));
});
device.cancelWhenDisconnected(syncSubscription);
await _setupHandshake(syncRxCharacteristic);
}
Future<void> _setupHandshake(BluetoothCharacteristic syncRxCharacteristic) async {
if (supportsEncryption) {
await syncRxCharacteristic.write([
...Constants.RIDE_ON,
...Constants.REQUEST_START,
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
], withoutResponse: true);
} else {
await syncRxCharacteristic.write(Constants.RIDE_ON, withoutResponse: true);
}
}
void _processCharacteristic(String tag, Uint8List bytes) {
if (kDebugMode && false) {
print('Received $tag: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}');
print('Received $tag: ${String.fromCharCodes(bytes)}');
}
if (bytes.isEmpty) {
return;
}
try {
if (bytes.startsWith(startCommand)) {
_processDevicePublicKeyResponse(bytes);
} else if (bytes.startsWith(Constants.RIDE_ON)) {
//print("Empty RideOn response - unencrypted mode");
} else if (!supportsEncryption || (bytes.length > Int32List.bytesPerElement + EncryptionUtils.MAC_LENGTH)) {
_processData(bytes);
} else if (bytes[0] == Constants.DISCONNECT_MESSAGE_TYPE) {
//print("Disconnect message");
} else {
//print("Unprocessed - Data Type: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
}
} catch (e, stackTrace) {
print("Error processing data: $e");
print("Stack Trace: $stackTrace");
actionStreamInternal.add(LogNotification(e.toString()));
}
}
ClickNotification? _lastClickNotification;
void _processData(Uint8List bytes) {
int type;
Uint8List message;
if (supportsEncryption) {
final counter = bytes.sublist(0, 4); // Int.SIZE_BYTES is 4
final payload = bytes.sublist(4);
final data = zapEncryption.decrypt(counter, payload);
type = data[0];
message = data.sublist(1);
} else {
type = bytes[0];
message = bytes.sublist(1);
}
switch (type) {
case Constants.EMPTY_MESSAGE_TYPE:
//print("Empty Message"); // expected when nothing happening
break;
case Constants.BATTERY_LEVEL_TYPE:
//print("Battery level update: $message");
break;
case Constants.CLICK_NOTIFICATION_MESSAGE_TYPE:
case Constants.PLAY_NOTIFICATION_MESSAGE_TYPE:
case Constants.RIDE_NOTIFICATION_MESSAGE_TYPE: // untested
processClickNotification(message);
break;
}
}
void _processDevicePublicKeyResponse(Uint8List bytes) {
final devicePublicKeyBytes = bytes.sublist(Constants.RIDE_ON.length + Constants.RESPONSE_START_CLICK.length);
zapEncryption.initialise(devicePublicKeyBytes);
if (kDebugMode) {
print("Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
}
}
void processClickNotification(Uint8List message) {
final ClickNotification clickNotification = ClickNotification(message);
if (_lastClickNotification == null || _lastClickNotification != clickNotification) {
_lastClickNotification = clickNotification;
actionStreamInternal.add(clickNotification);
if (clickNotification.buttonUp) {
actionHandler.increaseGear();
} else if (clickNotification.buttonDown) {
actionHandler.decreaseGear();
}
}
}
}

View File

@@ -1,30 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:swift_control/utils/devices/zwift_click.dart';
import 'package:swift_control/utils/messages/controller_notification.dart';
import '../../main.dart';
import '../ble.dart';
class ZwiftPlay extends ZwiftClick {
ZwiftPlay(super.scanResult);
ControllerNotification? _lastControllerNotification;
@override
List<int> get startCommand => Constants.RIDE_ON + Constants.RESPONSE_START_PLAY;
@override
void processClickNotification(Uint8List message) {
final ControllerNotification clickNotification = ControllerNotification(message);
if (_lastControllerNotification == null || _lastControllerNotification != clickNotification) {
_lastControllerNotification = clickNotification;
actionStreamInternal.add(clickNotification);
if (clickNotification.rightPad && clickNotification.analogLR.abs() == 100) {
actionHandler.increaseGear();
} else if (!clickNotification.rightPad && clickNotification.analogLR.abs() == 100) {
actionHandler.decreaseGear();
}
}
}
}

View File

@@ -1,14 +0,0 @@
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:swift_control/utils/devices/zwift_play.dart';
import '../ble.dart';
class ZwiftRide extends ZwiftPlay {
ZwiftRide(super.scanResult);
@override
Guid get customServiceId => BleUuid.ZWIFT_RIDE_CUSTOM_SERVICE_UUID;
@override
bool get supportsEncryption => false;
}

View File

@@ -1,12 +1,77 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
enum Keymap {
myWhoosh(increase: PhysicalKeyboardKey.keyK, decrease: PhysicalKeyboardKey.keyI),
indieVelo(increase: PhysicalKeyboardKey(0x70030), decrease: PhysicalKeyboardKey(0x70038)),
plusMinus(increase: PhysicalKeyboardKey(0x70030), decrease: PhysicalKeyboardKey(0x70038));
class Keymap {
static Keymap myWhoosh = Keymap(
'MyWhoosh',
increase: KeyPair(physicalKey: PhysicalKeyboardKey.keyK, logicalKey: LogicalKeyboardKey.keyK),
decrease: KeyPair(physicalKey: PhysicalKeyboardKey.keyI, logicalKey: LogicalKeyboardKey.keyI),
);
static Keymap custom = Keymap('Custom', increase: null, decrease: null);
final PhysicalKeyboardKey increase;
final PhysicalKeyboardKey decrease;
static List<Keymap> values = [myWhoosh, custom];
const Keymap({required this.increase, required this.decrease});
KeyPair? increase;
KeyPair? decrease;
final String name;
Keymap(this.name, {required this.increase, required this.decrease});
@override
String toString() {
if (increase == null && decrease == null) {
return name;
}
return "$name: ${increase?.logicalKey.keyLabel} + ${decrease?.logicalKey.keyLabel}";
}
List<String> encode() {
// encode to save in preferences
return [
name,
increase?.logicalKey.keyId.toString() ?? '',
increase?.physicalKey.usbHidUsage.toString() ?? '',
decrease?.logicalKey.keyId.toString() ?? '',
decrease?.physicalKey.usbHidUsage.toString() ?? '',
];
}
static Keymap? decode(List<String> data) {
// decode from preferences
if (data.length < 4) {
return null;
}
final name = data[0];
final keymap = values.firstOrNullWhere((element) => element.name == name);
if (keymap == null) {
return null;
}
if (keymap.name != custom.name) {
return keymap;
}
if (data.sublist(1).all((e) => e.isNotEmpty)) {
keymap.increase = KeyPair(
physicalKey: PhysicalKeyboardKey(int.parse(data[2])),
logicalKey: LogicalKeyboardKey(int.parse(data[1])),
);
keymap.decrease = KeyPair(
physicalKey: PhysicalKeyboardKey(int.parse(data[4])),
logicalKey: LogicalKeyboardKey(int.parse(data[3])),
);
return keymap;
} else {
return null;
}
}
}
class KeyPair {
final PhysicalKeyboardKey physicalKey;
final LogicalKeyboardKey logicalKey;
KeyPair({required this.physicalKey, required this.logicalKey});
}

View File

@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/requirements/platform.dart';
@@ -18,6 +19,51 @@ class AccessibilityRequirement extends PlatformRequirement {
}
}
class LocationRequirement extends PlatformRequirement {
LocationRequirement() : super('Allow Location Permission (req. by Samsung devices');
@override
Future<void> call() async {
await Permission.locationWhenInUse.request();
}
@override
Future<void> getStatus() async {
final state = await Permission.locationWhenInUse.status;
status = state.isGranted;
}
}
class BluetoothScanRequirement extends PlatformRequirement {
BluetoothScanRequirement() : super('Allow Bluetooth Scan');
@override
Future<void> call() async {
await Permission.bluetoothScan.request();
}
@override
Future<void> getStatus() async {
final state = await Permission.bluetoothScan.status;
status = state.isGranted || state.isLimited;
}
}
class BluetoothConnectRequirement extends PlatformRequirement {
BluetoothConnectRequirement() : super('Allow Bluetooth Connections');
@override
Future<void> call() async {
await Permission.bluetoothConnect.request();
}
@override
Future<void> getStatus() async {
final state = await Permission.bluetoothConnect.status;
status = state.isGranted || state.isLimited;
}
}
class NotificationRequirement extends PlatformRequirement {
NotificationRequirement() : super('Allow adding persistent Notification');

View File

@@ -1,9 +1,12 @@
import 'package:dartx/dartx.dart';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:swift_control/pages/scan.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/widgets/custom_keymap_selector.dart';
import 'package:universal_ble/universal_ble.dart';
import '../../main.dart';
import '../keymap/keymap.dart';
@@ -35,18 +38,29 @@ class KeymapRequirement extends PlatformRequirement {
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: DropdownMenu<Keymap>(
dropdownMenuEntries:
Keymap.values.map((key) => DropdownMenuEntry<Keymap>(value: key, label: key.name.capitalize())).toList(),
onSelected: (keymap) {
actionHandler.init(keymap);
onUpdate();
},
initialSelection: null,
hintText: 'Keymap',
),
final controller = TextEditingController(text: actionHandler.keymap?.name);
return DropdownMenu<Keymap>(
controller: controller,
dropdownMenuEntries:
Keymap.values.map((key) => DropdownMenuEntry<Keymap>(value: key, label: key.toString())).toList(),
onSelected: (keymap) async {
if (keymap!.name == Keymap.custom.name) {
keymap = await showCustomKeymapDialog(context, keymap: keymap);
} else if (keymap.name == Keymap.myWhoosh.name && (!kIsWeb && Platform.isWindows)) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Use a Custom Keymap if you experience any issues on Windows')));
}
controller.text = keymap?.name ?? '';
if (keymap == null) {
return;
}
actionHandler.init(keymap);
settings.setKeymap(keymap);
onUpdate();
},
initialSelection: actionHandler.keymap,
hintText: 'Keymap',
);
}
}
@@ -56,12 +70,13 @@ class BluetoothTurnedOn extends PlatformRequirement {
@override
Future<void> call() async {
return FlutterBluePlus.turnOn();
await UniversalBle.enableBluetooth();
}
@override
Future<void> getStatus() async {
status = FlutterBluePlus.adapterStateNow != BluetoothAdapterState.off;
final currentState = await UniversalBle.getBluetoothAvailabilityState();
status = currentState == AvailabilityState.poweredOn;
}
}

View File

@@ -29,7 +29,15 @@ Future<List<PlatformRequirement>> getRequirements() async {
} else if (Platform.isWindows) {
list = [BluetoothTurnedOn(), KeyboardRequirement(), KeymapRequirement(), BluetoothScanning()];
} else if (Platform.isAndroid) {
list = [BluetoothTurnedOn(), AccessibilityRequirement(), NotificationRequirement(), BluetoothScanning()];
list = [
BluetoothTurnedOn(),
AccessibilityRequirement(),
LocationRequirement(),
NotificationRequirement(),
BluetoothScanRequirement(),
BluetoothConnectRequirement(),
BluetoothScanning(),
];
} else {
list = [UnsupportedPlatform()];
}

View File

@@ -0,0 +1,43 @@
import 'dart:ui';
import 'package:shared_preferences/shared_preferences.dart';
import '../../main.dart';
import '../keymap/keymap.dart';
class Settings {
late final SharedPreferences _prefs;
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
try {
final keymapSetting = _prefs.getStringList("keymap");
if (keymapSetting != null) {
actionHandler.init(Keymap.decode(keymapSetting));
}
final gearUpX = _prefs.getDouble("gearUpX");
final gearUpY = _prefs.getDouble("gearUpY");
final gearDownX = _prefs.getDouble("gearDownX");
final gearDownY = _prefs.getDouble("gearDownY");
if (gearUpX != null && gearUpY != null && gearDownX != null && gearDownY != null) {
actionHandler.updateTouchPositions(Offset(gearUpX, gearUpY), Offset(gearDownX, gearDownY));
}
} catch (e) {
// couldn't decode, reset
await _prefs.clear();
}
}
void setKeymap(Keymap keymap) {
_prefs.setStringList("keymap", keymap.encode());
}
void updateTouchPositions(Offset gearUp, Offset gearDown) {
_prefs.setDouble("gearUpX", gearUp.dx);
_prefs.setDouble("gearUpY", gearUp.dy);
_prefs.setDouble("gearDownX", gearDown.dx);
_prefs.setDouble("gearDownY", gearDown.dy);
}
}

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:swift_control/utils/keymap/keymap.dart';
Future<Keymap?> showCustomKeymapDialog(BuildContext context, {required Keymap keymap}) {
return showDialog<Keymap>(
context: context,
builder: (context) {
return GearHotkeyDialog(keymap: keymap);
},
);
}
class GearHotkeyDialog extends StatefulWidget {
final Keymap keymap;
const GearHotkeyDialog({super.key, required this.keymap});
@override
State<GearHotkeyDialog> createState() => _GearHotkeyDialogState();
}
class _GearHotkeyDialogState extends State<GearHotkeyDialog> {
final FocusNode _focusNode = FocusNode();
KeyDownEvent? _pressedKey;
KeyDownEvent? _gearUpHotkey;
KeyDownEvent? _gearDownHotkey;
String _mode = 'up'; // 'up' or 'down'
@override
void initState() {
super.initState();
_focusNode.requestFocus();
}
void _onKey(KeyEvent event) {
setState(() {
if (event is KeyDownEvent) {
_pressedKey = event;
} else if (event is KeyUpEvent) {
if (_pressedKey != null) {
if (_mode == 'up') {
_gearUpHotkey = _pressedKey;
_mode = 'down';
} else {
_gearDownHotkey = _pressedKey;
widget.keymap.increase = KeyPair(
physicalKey: _gearUpHotkey!.physicalKey,
logicalKey: _gearUpHotkey!.logicalKey,
);
widget.keymap.decrease = KeyPair(
physicalKey: _gearDownHotkey!.physicalKey,
logicalKey: _gearDownHotkey!.logicalKey,
);
Navigator.of(context).pop(widget.keymap);
}
_pressedKey = null;
}
}
});
}
String _formatKey(KeyDownEvent? key) {
return key?.logicalKey.keyLabel ?? 'Not set';
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Set Gear Hotkeys'),
content: KeyboardListener(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: _onKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("Step 1: Press a hotkey for **Gear Up**."),
Text("Step 2: Press a hotkey for **Gear Down**."),
SizedBox(height: 20),
ListTile(
leading: Icon(Icons.arrow_upward),
title: Text("Gear Up Hotkey"),
subtitle: Text(_formatKey(_gearUpHotkey)),
),
ListTile(
leading: Icon(Icons.arrow_downward),
title: Text("Gear Down Hotkey"),
subtitle: Text(_formatKey(_gearDownHotkey)),
),
],
),
),
actions: [TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text("Cancel"))],
);
}
}

View File

@@ -0,0 +1,68 @@
import 'dart:async';
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import '../bluetooth/messages/notification.dart';
import '../main.dart';
class LogViewer extends StatefulWidget {
const LogViewer({super.key});
@override
State<LogViewer> createState() => _LogviewerState();
}
class _LogviewerState extends State<LogViewer> {
List<String> _actions = [];
late StreamSubscription<BaseNotification> _actionSubscription;
@override
void initState() {
super.initState();
_actionSubscription = connection.actionStream.listen((data) {
if (mounted) {
setState(() {
_actions.add('${DateTime.now().toString().split(" ").last}: $data');
_actions = _actions.takeLast(30).toList();
});
}
});
}
@override
void dispose() {
_actionSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
ListView(
shrinkWrap: true,
children:
_actions
.map(
(action) =>
Text(action, style: TextStyle(fontSize: 12, fontFeatures: [FontFeature.tabularFigures()])),
)
.toList(),
),
Align(
alignment: Alignment.topRight,
child: IconButton(
onPressed: () {
_actions.clear();
setState(() {});
},
icon: Icon(Icons.clear),
),
),
],
);
}
}

61
lib/widgets/menu.dart Normal file
View File

@@ -0,0 +1,61 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:url_launcher/url_launcher_string.dart';
List<Widget> buildMenuButtons() {
return [
TextButton(
onPressed: () {
launchUrlString('https://paypal.me/boni');
},
child: Text('Donate ♥'),
),
const MenuButton(),
SizedBox(width: 8),
];
}
class MenuButton extends StatelessWidget {
const MenuButton({super.key});
@override
Widget build(BuildContext context) {
return PopupMenuButton(
itemBuilder:
(c) => [
if (kDebugMode) ...[
PopupMenuItem(
child: Text('Gear up'),
onTap: () {
Future.delayed(Duration(seconds: 2)).then((_) {
actionHandler.increaseGear();
});
},
),
PopupMenuItem(
child: Text('Gear down'),
onTap: () {
Future.delayed(Duration(seconds: 2)).then((_) {
actionHandler.decreaseGear();
});
},
),
PopupMenuItem(child: PopupMenuDivider()),
],
PopupMenuItem(
child: Text('Feedback'),
onTap: () {
launchUrlString('https://github.com/jonasbark/swiftcontrol/issues');
},
),
PopupMenuItem(
child: Text('License'),
onTap: () {
showLicensePage(context: context);
},
),
],
);
}
}

View File

@@ -6,6 +6,14 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View File

@@ -3,6 +3,8 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -5,14 +5,18 @@
import FlutterMacOS
import Foundation
import flutter_blue_plus_darwin
import file_selector_macos
import flutter_local_notifications
import keypress_simulator_macos
import path_provider_foundation
import shared_preferences_foundation
import universal_ble
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
KeypressSimulatorMacosPlugin.register(with: registry.registrar(forPlugin: "KeypressSimulatorMacosPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@@ -1,41 +1,53 @@
PODS:
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- file_selector_macos (0.0.1):
- FlutterMacOS
- flutter_local_notifications (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- keypress_simulator_macos (0.0.1):
- FlutterMacOS
- path_provider_foundation (0.0.1):
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- universal_ble (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES:
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- keypress_simulator_macos (from `Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- universal_ble (from `Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
EXTERNAL SOURCES:
flutter_blue_plus_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
flutter_local_notifications:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
FlutterMacOS:
:path: Flutter/ephemeral
keypress_simulator_macos:
:path: Flutter/ephemeral/.symlinks/plugins/keypress_simulator_macos/macos
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
universal_ble:
:path: Flutter/ephemeral/.symlinks/plugins/universal_ble/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS:
flutter_blue_plus_darwin: 3ea4ec9133b377febcc8a70b28cd2d2dc9242bd9
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
flutter_local_notifications: 4ccab5b7a22835214a6672e3f9c5e8ae207dab36
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
keypress_simulator_macos: f8556f9101f9f2f175652e0bceddf0fe82a4c6b2
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82

View File

@@ -269,7 +269,6 @@
33CC10EC2044A3C60003C045 = {
CreatedOnToolsVersion = 9.2;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
SystemCapabilities = {
com.apple.Sandbox = {
enabled = 1;
@@ -571,15 +570,19 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = 65H3XQQ399;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftControl;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
@@ -705,15 +708,19 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = 65H3XQQ399;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftControl;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -727,15 +734,19 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
CODE_SIGN_STYLE = Automatic;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Mac Developer";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=macosx*]" = 65H3XQQ399;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = de.jonasbark.swiftControl;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};

View File

@@ -10,5 +10,7 @@
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>

View File

@@ -22,13 +22,13 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>We need BT access because it's a BT App.</string>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>We need BT access because it's a BT App.</string>
</dict>
</plist>

View File

@@ -96,6 +96,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
url: "https://pub.dev"
source: hosted
version: "0.3.4+2"
crypto:
dependency: transitive
description:
@@ -136,6 +144,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33"
url: "https://pub.dev"
source: hosted
version: "0.9.3+2"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc"
url: "https://pub.dev"
source: hosted
version: "0.9.4+2"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b
url: "https://pub.dev"
source: hosted
version: "2.6.2"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b"
url: "https://pub.dev"
source: hosted
version: "0.9.3+4"
fixnum:
dependency: transitive
description:
@@ -165,62 +213,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_blue_plus:
dependency: "direct main"
description:
name: flutter_blue_plus
sha256: "2d926dbef0fd6c58d4be8fca9eaaf1ba747c0ccb8373ddd5386665317e26eb61"
url: "https://pub.dev"
source: hosted
version: "1.35.3"
flutter_blue_plus_android:
dependency: transitive
description:
name: flutter_blue_plus_android
sha256: c1d83f84b514e46345a8a58599c428f20b11e78379521e0d3b0611c7b7cbf2c1
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_blue_plus_darwin:
dependency: transitive
description:
name: flutter_blue_plus_darwin
sha256: "8d0a0f11f83b13dda173396b7e4028b4e8656bc8dbbc82c26a7e49aafc62644b"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_blue_plus_linux:
dependency: transitive
description:
name: flutter_blue_plus_linux
sha256: "1d367ed378b2bd6c3b9685fda7044e1d2f169884802b7dec7badb31a99a72660"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_blue_plus_platform_interface:
dependency: transitive
description:
name: flutter_blue_plus_platform_interface
sha256: "114f8e85a03a28a48d707a4df6cc9218e1f2005cf260c5e815e5585a00da5778"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_blue_plus_web:
dependency: "direct overridden"
description:
path: "packages/flutter_blue_plus_web"
ref: HEAD
resolved-ref: "5fc128ed6ff6b1bc038ec9f91a6d9789c2cebc53"
url: "https://github.com/chipweinberger/flutter_blue_plus.git"
source: git
version: "3.0.0"
flutter_blue_plus_windows:
dependency: "direct main"
description:
path: flutter_blue_plus_windows
relative: true
source: path
version: "1.26.1"
flutter_launcher_icons:
dependency: "direct dev"
description:
@@ -269,11 +261,27 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3"
url: "https://pub.dev"
source: hosted
version: "2.0.27"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_bluetooth:
dependency: transitive
description:
name: flutter_web_bluetooth
sha256: "1363831def5eed1e1064d1eca04e8ccb35446e8f758579c3c519e156b77926da"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
@@ -303,6 +311,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.4"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9"
url: "https://pub.dev"
source: hosted
version: "0.8.12+22"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100"
url: "https://pub.dev"
source: hosted
version: "0.8.12+2"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9"
url: "https://pub.dev"
source: hosted
version: "0.2.1+2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "1b90ebbd9dcf98fb6c1d01427e49a55bd96b5d67b8c67cf955d60a5de74207c1"
url: "https://pub.dev"
source: hosted
version: "0.2.1+2"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "886d57f0be73c4b140004e78b9f28a8914a09e50c2d816bdd0520051a71236a0"
url: "https://pub.dev"
source: hosted
version: "2.10.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
json_annotation:
dependency: transitive
description:
@@ -375,6 +447,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -399,6 +479,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
path:
dependency: transitive
description:
@@ -407,30 +495,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12"
url: "https://pub.dev"
source: hosted
version: "2.2.16"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
path_provider_linux:
dependency: transitive
description:
@@ -455,6 +519,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev"
source: hosted
version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev"
source: hosted
version: "12.1.0"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f84a188e79a35c687c132a0a0556c254747a08561e99ab933f12f6ca71ef3c98
url: "https://pub.dev"
source: hosted
version: "9.4.6"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:
@@ -503,14 +615,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.0"
rxdart:
dependency: transitive
shared_preferences:
dependency: "direct main"
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
name: shared_preferences
sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
version: "2.5.3"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "3ec7210872c4ba945e3244982918e502fa2bfb5230dff6832459ca0e1879b7ad"
url: "https://pub.dev"
source: hosted
version: "2.4.8"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
@@ -540,14 +700,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_with_value:
dependency: transitive
description:
name: stream_with_value
sha256: "483d79bf604fdea5274e31207956b2f624f5f03a506cacf081b65cdfcfa647a6"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
string_scanner:
dependency: transitive
description:
@@ -604,6 +756,78 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.1.3"
universal_ble:
dependency: "direct main"
description:
name: universal_ble
sha256: "35d210e93a5938c6a6d1fd3c710cf4ac90b1bdd1b11c8eb2beeb32600672e6e6"
url: "https://pub.dev"
source: hosted
version: "0.17.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
url: "https://pub.dev"
source: hosted
version: "6.3.1"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "1d0eae19bd7606ef60fe69ef3b312a437a16549476c42321d5dc1506c9ca3bf4"
url: "https://pub.dev"
source: hosted
version: "6.3.15"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626"
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "3ba963161bd0fe395917ba881d320b9c4f6dd3c4a233da62ab18a5025c85f1e9"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
vector_math:
dependency: transitive
description:
@@ -628,14 +852,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win_ble:
dependency: transitive
description:
name: win_ble
sha256: "2a867e13c4b355b101fc2c6e2ac85eeebf965db34eca46856f8b478e93b41e96"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:

View File

@@ -1,7 +1,7 @@
name: swift_control
description: "SwiftControl - Control your virtual riding"
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1
version: 1.1.4+0
environment:
sdk: ^3.7.0
@@ -10,24 +10,20 @@ dependencies:
flutter:
sdk: flutter
url_launcher: ^6.3.1
flutter_local_notifications: ^19.0.0
flutter_blue_plus: ^1.35.3
universal_ble: any
protobuf: ^3.1.0
permission_handler: ^11.4.0
dartx: any
image_picker: ^1.1.2
pointycastle: any
keypress_simulator: ^0.2.0
shared_preferences: ^2.5.3
flex_color_scheme: ^8.2.0
flutter_blue_plus_windows:
path: flutter_blue_plus_windows
accessibility:
path: accessibility
dependency_overrides:
flutter_blue_plus_web:
git:
url: https://github.com/chipweinberger/flutter_blue_plus.git
path: packages/flutter_blue_plus_web
dev_dependencies:
flutter_test:
sdk: flutter

View File

@@ -1,19 +1,6 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
@@ -29,7 +16,7 @@
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>swift_play</title>
<title>SwiftControl</title>
<link rel="manifest" href="manifest.json">
</head>
<body>

294
web/zwift_ride.html Normal file
View File

@@ -0,0 +1,294 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zwift Ride Scanner (Protocol Buffers)</title>
</head>
<body>
<!-- thanks to https://www.makinolo.com/blog/2024/07/26/zwift-ride-protocol/ and https://www.makinolo.com/blog/2023/10/08/connecting-to-zwift-play-controllers/ -->
<h1>Zwift Ride Scanner</h1>
<button onclick="scanForDevices()">Scan for Devices</button>
<div id="status">Status: Disconnected</div>
<pre id="log" style="white-space: pre-wrap"></pre>
<script>
const statusDiv = document.getElementById("status");
const logDiv = document.getElementById("log");
let controlCharacteristic = null;
const BUTTON_MASKS = {
LEFT_BTN: 0x1,
UP_BTN: 0x2,
RIGHT_BTN: 0x4,
DOWN_BTN: 0x8,
A_BTN: 0x10,
B_BTN: 0x20,
Y_BTN: 0x40,
Z_BTN: 0x100,
SHFT_UP_L_BTN: 0x200,
SHFT_DN_L_BTN: 0x400,
POWERUP_L_BTN: 0x800,
ONOFF_L_BTN: 0x1000,
SHFT_UP_R_BTN: 0x2000,
SHFT_DN_R_BTN: 0x4000,
POWERUP_R_BTN: 0x10000,
ONOFF_R_BTN: 0x20000,
};
function log(message) {
const timestamp = new Date().toLocaleTimeString();
logDiv.textContent += `[${timestamp}] ${message}\n`;
console.log(message);
}
function parseKeyPress(buffer) {
let location = null;
let analogValue = null;
let offset = 0;
while (offset < buffer.length) {
const tag = buffer[offset];
const fieldNum = tag >> 3;
const wireType = tag & 0x7;
offset++;
switch (fieldNum) {
case 1: // Location
if (wireType === 0) {
let value = 0;
let shift = 0;
while (true) {
const byte = buffer[offset++];
value |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) break;
shift += 7;
}
location = value;
}
break;
case 2: // AnalogValue
if (wireType === 0) {
let value = 0;
let shift = 0;
while (true) {
const byte = buffer[offset++];
value |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) break;
shift += 7;
}
// ZigZag decode for sint32
analogValue = (value >>> 1) ^ -(value & 1);
}
break;
default:
// Skip unknown fields
if (wireType === 0) {
while (buffer[offset++] & 0x80);
} else if (wireType === 2) {
const length = buffer[offset++];
offset += length;
}
}
}
return { location: location, value: analogValue };
}
function parseKeyGroup(buffer) {
let groupStatus = {};
let offset = 0;
while (offset < buffer.length) {
const tag = buffer[offset];
const fieldNum = tag >> 3;
const wireType = tag & 0x7;
offset++;
if (fieldNum === 3 && wireType === 2) {
const length = buffer[offset++];
const messageBuffer = buffer.slice(
offset,
offset + length,
);
let res = parseKeyPress(messageBuffer);
groupStatus[res.location] = res.value;
offset += length;
} else {
// Skip unknown fields
if (wireType === 0) {
while (buffer[offset++] & 0x80);
} else if (wireType === 2) {
const length = buffer[offset++];
offset += length;
}
}
}
return groupStatus;
}
function parseButtonState(buttonMap) {
const pressedButtons = [];
for (const [button, mask] of Object.entries(BUTTON_MASKS)) {
if ((buttonMap & mask) === 0) {
pressedButtons.push(button);
}
}
return pressedButtons;
}
function parseAnalogMessage(data) {
// Each analog group starts with 0x1a
if (data[0] !== 0x1a) return null;
let res = parseKeyGroup(data);
return {
left: "0" in res ? res["0"] : 0,
right: "1" in res ? res["1"] : 0,
};
}
function handleMessage(value) {
const data = new Uint8Array(value.buffer);
const msgType = data[0];
switch (msgType) {
case 0x23: {
// Button status
const buttonMap =
data[2] |
(data[3] << 8) |
(data[4] << 16) |
(data[5] << 24);
const pressedButtons = parseButtonState(buttonMap);
if (pressedButtons.length > 0) {
log(
`Buttons pressed! ${pressedButtons.join(", ")}`,
);
}
// Find analog values section (after button map)
let startIndex = 7; // Skip message type, field number, and button map
while (startIndex < data.length) {
const analogData = parseAnalogMessage(
data.slice(startIndex),
);
if (!analogData) break;
log(
`Analog left:${analogData.left} right:${analogData.right}`,
);
startIndex = analogData.nextIndex;
}
break;
}
case 0x2a: // Initial status
log("Initial status received");
break;
case 0x15: // Idle
break;
case 0x19: // Status update
break;
default:
log(
`Unknown message: ${Array.from(data)
.map((b) => b.toString(16).padStart(2, "0"))
.join(" ")}`,
);
}
}
async function scanForDevices() {
if (!navigator.bluetooth) {
statusDiv.textContent =
"Status: Web Bluetooth API is not supported";
return;
}
try {
statusDiv.textContent = "Status: Scanning...";
logDiv.textContent = "";
const device = await navigator.bluetooth.requestDevice({
filters: [
{
name: "Zwift Ride",
},
],
optionalServices: [
"0000180f-0000-1000-8000-00805f9b34fb", // Battery Service
"0000180a-0000-1000-8000-00805f9b34fb", // Device Information
"0000fc82-0000-1000-8000-00805f9b34fb", // Custom Service
],
});
log(`Device name: ${device.name}`);
log(`Device ID: ${device.id}`);
statusDiv.textContent = "Status: Connecting...";
const server = await device.gatt.connect();
log("Connected to GATT server");
const service = await server.getPrimaryService(
"0000fc82-0000-1000-8000-00805f9b34fb",
);
log("Found custom service");
const measurementChar = await service.getCharacteristic(
"00000002-19ca-4651-86e5-fa29dcdd09d1",
);
controlCharacteristic = await service.getCharacteristic(
"00000003-19ca-4651-86e5-fa29dcdd09d1",
);
const responseChar = await service.getCharacteristic(
"00000004-19ca-4651-86e5-fa29dcdd09d1",
);
log("Got service characteristics");
// Initial handshake
const handshake = new TextEncoder().encode("RideOn");
await controlCharacteristic.writeValue(handshake);
log("Sent RideOn handshake");
// Set up notifications
await measurementChar.startNotifications();
measurementChar.addEventListener(
"characteristicvaluechanged",
(event) => {
handleMessage(event.target.value);
},
);
await responseChar.startNotifications();
responseChar.addEventListener(
"characteristicvaluechanged",
(event) => {
const value = new TextDecoder().decode(
event.target.value,
);
log(`Response: ${value}`);
},
);
device.addEventListener("gattserverdisconnected", () => {
statusDiv.textContent = "Status: Device disconnected";
log("Device disconnected");
});
statusDiv.textContent =
"Status: Connected and watching for input";
} catch (error) {
statusDiv.textContent = `Status: Error - ${error}`;
log(`Error: ${error.message}`);
console.error("Error:", error);
}
}
</script>
</body>
</html>

View File

@@ -1,10 +1,10 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.14)
project(swift_play LANGUAGES CXX)
project(swift_control LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "swift_play")
set(BINARY_NAME "swift_control")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.

View File

@@ -6,9 +6,21 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <keypress_simulator_windows/keypress_simulator_windows_plugin_c_api.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <universal_ble/universal_ble_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
KeypressSimulatorWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("KeypressSimulatorWindowsPluginCApi"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
UniversalBlePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UniversalBlePluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@@ -3,7 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
keypress_simulator_windows
permission_handler_windows
universal_ble
url_launcher_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -90,12 +90,12 @@ BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "de.jonasbark" "\0"
VALUE "FileDescription", "swift_play" "\0"
VALUE "FileDescription", "swift_control" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "swift_play" "\0"
VALUE "InternalName", "swift_control" "\0"
VALUE "LegalCopyright", "Copyright (C) 2025 de.jonasbark. All rights reserved." "\0"
VALUE "OriginalFilename", "swift_play.exe" "\0"
VALUE "ProductName", "swift_play" "\0"
VALUE "OriginalFilename", "swift_control.exe" "\0"
VALUE "ProductName", "swift_control" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"
END
END

View File

@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project);
Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720);
if (!window.Create(L"swift_play", origin, size)) {
if (!window.Create(L"swift_control", origin, size)) {
return EXIT_FAILURE;
}
window.SetQuitOnClose(true);