diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..901011e08
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,2 @@
+github: [jonasbark]
+custom: ["https://paypal.me/boni", "https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200"]
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 000000000..145c59f8c
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,348 @@
+name: "Build"
+
+on:
+ workflow_dispatch:
+ inputs:
+ build_mac:
+ description: 'Build for macOS'
+ required: false
+ default: true
+ type: boolean
+ build_github:
+ description: 'Build for GitHub'
+ required: false
+ default: true
+ type: boolean
+ build_windows:
+ description: 'Build for Windows'
+ required: false
+ default: true
+ type: boolean
+ build_android:
+ description: 'Build for Android'
+ required: false
+ default: true
+ type: boolean
+ build_ios:
+ description: 'Build for iOS'
+ required: false
+ default: true
+ type: boolean
+
+env:
+ SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
+ FLUTTER_VERSION: 3.41.4
+
+jobs:
+ build:
+ 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
+ with:
+ submodules: recursive
+ token: ${{ secrets.PAT_TOKEN }}
+
+ - name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml
+ run: |
+ if [ -f pubspec_overrides_ci.yaml ]; then
+ mv pubspec_overrides_ci.yaml pubspec_overrides.yaml
+ else
+ echo "No pubspec_overrides_ci.yaml found, skipping rename."
+ fi
+
+ - name: Install certificates
+ if: inputs.build_mac || inputs.build_ios
+ env:
+ DEVELOPER_ID_APPLICATION_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64_MAC }}
+ DEVELOPER_ID_INSTALLER_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_INSTALLER_P12_BASE64_MAC }}
+ P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
+ KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
+ APPSTORE_PROFILE_IOS_BASE64: ${{ secrets.APPSTORE_PROFILE_IOS_BASE64 }}
+ APPSTORE_PROFILE_MACOS_BASE64: ${{ secrets.APPSTORE_PROFILE_MACOS_BASE64 }}
+ APPSTORE_PROFILE_DEV_IOS_BASE64: ${{ secrets.APPSTORE_PROFILE_DEV_IOS_BASE64 }}
+ run: |
+ # create variables
+ DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH=$RUNNER_TEMP/build_developerID_application_certificate.p12
+ DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH=$RUNNER_TEMP/build_developerID_installer_certificate.p12
+ PP_PATH_IOS=$RUNNER_TEMP/build_pp_ios.mobileprovision
+ PP_PATH_IOS_DEV=$RUNNER_TEMP/build_pp_ios_dev.mobileprovision
+ PP_PATH_MACOS=$RUNNER_TEMP/build_pp_macos.provisionprofile
+ 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
+ echo -n "$DEVELOPER_ID_INSTALLER_P12_BASE64_MAC" | base64 --decode --output $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH
+ echo -n "$APPSTORE_PROFILE_IOS_BASE64" | base64 --decode -o $PP_PATH_IOS
+ echo -n "$APPSTORE_PROFILE_DEV_IOS_BASE64" | base64 --decode -o $PP_PATH_IOS_DEV
+ echo -n "$APPSTORE_PROFILE_MACOS_BASE64" | base64 --decode -o $PP_PATH_MACOS
+
+ # 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 import $DEVELOPER_ID_INSTALLER_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
+
+ mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
+ cp $PP_PATH_IOS ~/Library/MobileDevice/Provisioning\ Profiles
+ cp $PP_PATH_IOS_DEV ~/Library/MobileDevice/Provisioning\ Profiles
+ cp $PP_PATH_MACOS ~/Library/MobileDevice/Provisioning\ Profiles
+
+ - name: 🐦 Setup Shorebird
+ #if: inputs.build_mac || inputs.build_android || inputs.build_ios
+ if: inputs.build_android || inputs.build_ios
+ uses: shorebirdtech/setup-shorebird@v1
+ with:
+ cache: true
+
+ - name: Set Up Flutter maCOS
+ uses: subosito/flutter-action@v2
+ with:
+ channel: 'stable'
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+
+ - name: Generate translation files
+ run: |
+ flutter pub global activate intl_utils;
+ flutter pub global run intl_utils:generate;
+
+ - name: 🚀 Shorebird Release macOS
+ if: inputs.build_mac && false
+ uses: shorebirdtech/shorebird-release@v1
+ with:
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+ platform: macos
+ args: "--obfuscate --split-debug-info=symbols -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}"
+
+ - name: Flutter Release macOS
+ if: inputs.build_mac
+ run:
+ flutter build macos --release --obfuscate --split-debug-info=symbols --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}
+
+ - name: Decode Keystore
+ if: inputs.build_android
+ run: |
+ echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore;
+ echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
+
+ - name: 🚀 Shorebird Release Android
+ if: inputs.build_android
+ uses: shorebirdtech/shorebird-release@v1
+ with:
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+ platform: android
+ args: "--obfuscate --split-debug-info=symbols -- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}"
+
+ - name: Extract latest changelog
+ id: changelog
+ run: |
+ chmod +x scripts/get_latest_changelog.sh
+ mkdir -p whatsnew
+ ./scripts/get_latest_changelog.sh | head -c 500 > whatsnew/whatsnew-en-US
+
+ - name: Generate release body
+ if: inputs.build_github
+ run: |
+ chmod +x scripts/generate_release_body.sh
+ ./scripts/generate_release_body.sh > /tmp/release_body.md
+
+ - name: 🚀 Shorebird Release iOS
+ if: inputs.build_ios
+ uses: shorebirdtech/shorebird-release@v1
+ with:
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+ platform: ios
+ args: "--export-options-plist ios/ExportOptions.plist --obfuscate --split-debug-info=symbols -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}"
+
+ - name: Prepare App Store authentication key
+ if: inputs.build_ios || inputs.build_mac
+ env:
+ API_KEY_BASE64: ${{ secrets.APPSTORE_API_KEY_FILE_BASE64 }}
+ APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
+ run: |
+ mkdir -p ./private_keys;
+ printf %s "$API_KEY_BASE64" | base64 -D > "./private_keys/AuthKey_${APPSTORE_API_KEY}.p8";
+
+ - name: Upload to Play Store
+ if: inputs.build_android
+ uses: r0adkll/upload-google-play@v1
+ with:
+ serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
+ packageName: de.jonasbark.swiftcontrol
+ releaseFiles: build/app/outputs/bundle/release/app-release.aab
+ track: ${{ github.ref == 'refs/heads/main' && 'production' || 'alpha' }}
+ whatsNewDirectory: whatsnew
+
+ - name: Upload to macOS App Store
+ if: inputs.build_mac
+ env:
+ APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
+ APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
+ run: |
+ productbuild --component "build/macos/Build/Products/Release/BikeControl.app" /Applications "BikeControl.pkg" --sign "3rd Party Mac Developer Installer: JONAS TASSILO BARK (UZRHKPVWN9)";
+ xcrun altool --upload-app -f BikeControl.pkg -t osx --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
+
+ - name: Upload to iOS App Store
+ if: inputs.build_ios
+ env:
+ APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }}
+ APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
+ run: |
+ xcrun altool --upload-app -f build/ios/ipa/swift_play.ipa -t ios --apiKey "$APPSTORE_API_KEY" --apiIssuer "$APPSTORE_API_ISSUER_ID";
+
+
+ #10 Extract Version
+ - name: Extract version from pubspec.yaml
+ if: inputs.build_github
+ id: extract_version
+ run: |
+ version=$(grep '^version: ' pubspec.yaml | cut -d ' ' -f 2 | tr -d '\r')
+ echo "VERSION=$version" >> $GITHUB_ENV
+
+ - name: Upload Symbols
+ uses: actions/upload-artifact@v4
+ with:
+ overwrite: true
+ name: Symbols
+ path: symbols/
+
+ #13 Create Release
+ - name: Create Release
+ if: inputs.build_github
+ uses: ncipollo/release-action@v1
+ with:
+ artifacts: "build/app/outputs/flutter-apk/BikeControl.android.apk,build/macos/Build/Products/Release/BikeControl.macos.zip"
+ allowUpdates: true
+ prerelease: true
+ bodyFile: /tmp/release_body.md
+ tag: v${{ env.VERSION }}
+ token: ${{ secrets.TOKEN }}
+
+ windows:
+ if: inputs.build_windows
+ name: Build & Release on Windows
+ runs-on: windows-2025
+
+ steps:
+ #1 Checkout Repository
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+ with:
+ submodules: recursive
+ token: ${{ secrets.PAT_TOKEN }}
+
+ - name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml
+ shell: pwsh
+ run: |
+ if (Test-Path pubspec_overrides_ci.yaml) {
+ Rename-Item -Path pubspec_overrides_ci.yaml -NewName pubspec_overrides.yaml
+ } else {
+ Write-Output "No pubspec_overrides_ci.yaml found, skipping rename."
+ }
+
+ - 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
+
+ - name: 🐦 Setup Shorebird
+ uses: shorebirdtech/setup-shorebird@v1
+ with:
+ cache: true
+
+ - name: Set Up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: 'stable'
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+
+ - name: Generate translation files
+ run: |
+ flutter pub global activate intl_utils;
+ flutter pub global run intl_utils:generate;
+
+ - name: 🚀 Shorebird Release Windows
+ uses: shorebirdtech/shorebird-release@v1
+ with:
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+ platform: windows
+ args: "--obfuscate --split-debug-info=symbols-win -- --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}"
+
+ - name: Zip Windows release
+ shell: pwsh
+ run: |
+ $source = "C:\Windows\System32"
+ $destination = "build\windows\x64\runner\Release"
+
+ # List of required DLLs
+ $dlls = @("msvcp140.dll", "vcruntime140.dll", "vcruntime140_1.dll")
+
+ # Copy each file
+ foreach ($dll in $dlls) {
+ $srcPath = Join-Path $source $dll
+ $destPath = Join-Path $destination $dll
+
+ if (Test-Path $srcPath) {
+ Copy-Item -Path $srcPath -Destination $destPath -Force
+ Write-Output "Copied $dll to $destination"
+ } else {
+ Write-Warning "$dll not found in $source"
+ }
+ }
+ $zipPath = "build/windows/bike_control.windows.zip"
+ if (Test-Path $zipPath) {
+ Remove-Item $zipPath -Force
+ }
+ Compress-Archive -Path "build/windows/x64/runner/Release/*" -DestinationPath $zipPath
+
+ - name: Create Store MSIX package
+ run: dart run msix:create --store --output-name bike_control.store
+
+ - name: Upload Store Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ overwrite: true
+ name: BikeControl Store
+ path: build/windows/x64/runner/Release/bike_control.store.msix
+
+ - name: Upload Windows artifact
+ uses: actions/upload-artifact@v4
+ with:
+ overwrite: true
+ name: Windows Release
+ path: build/windows/bike_control.windows.zip
+
+ - name: Upload Artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ overwrite: true
+ name: Windows Symbols
+ path: symbols-win/
+
+ - name: Create Release (Windows zip)
+ if: inputs.build_github
+ uses: ncipollo/release-action@v1
+ with:
+ artifacts: "build/windows/bike_control.windows.zip"
+ allowUpdates: true
+ prerelease: true
+ replacesArtifacts: false
+ tag: v${{ env.VERSION }}
+ token: ${{ secrets.TOKEN }}
diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml
new file mode 100644
index 000000000..f3911754a
--- /dev/null
+++ b/.github/workflows/patch.yml
@@ -0,0 +1,163 @@
+name: "Patch"
+
+on:
+ workflow_dispatch:
+
+env:
+ SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }}
+ FLUTTER_VERSION: 3.38.5
+
+jobs:
+ build:
+ name: Patch iOS, Android & macOS
+ runs-on: macos-latest
+
+ permissions:
+ id-token: write
+ pages: write
+ contents: write
+
+ steps:
+ #1 Checkout Repository
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+ with:
+ submodules: recursive
+ token: ${{ secrets.PAT_TOKEN }}
+
+ - name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml
+ run: |
+ if [ -f pubspec_overrides_ci.yaml ]; then
+ mv pubspec_overrides_ci.yaml pubspec_overrides.yaml
+ else
+ echo "No pubspec_overrides_ci.yaml found, skipping rename."
+ fi
+
+ - name: 🐦 Setup Shorebird
+ uses: shorebirdtech/setup-shorebird@v1
+ with:
+ cache: true
+
+ - name: Set Up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: 'stable'
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+
+ - name: Generate translation files
+ run: |
+ flutter pub global activate intl_utils;
+ flutter pub global run intl_utils:generate;
+
+ - name: Install certificates
+ env:
+ DEVELOPER_ID_APPLICATION_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64_MAC }}
+ DEVELOPER_ID_INSTALLER_P12_BASE64_MAC: ${{ secrets.DEVELOPER_ID_INSTALLER_P12_BASE64_MAC }}
+ P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
+ KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
+ APPSTORE_PROFILE_IOS_BASE64: ${{ secrets.APPSTORE_PROFILE_IOS_BASE64 }}
+ APPSTORE_PROFILE_MACOS_BASE64: ${{ secrets.APPSTORE_PROFILE_MACOS_BASE64 }}
+ APPSTORE_PROFILE_DEV_IOS_BASE64: ${{ secrets.APPSTORE_PROFILE_DEV_IOS_BASE64 }}
+ run: |
+ # create variables
+ DEVELOPER_ID_APPLICATION_CERTIFICATE_PATH=$RUNNER_TEMP/build_developerID_application_certificate.p12
+ DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH=$RUNNER_TEMP/build_developerID_installer_certificate.p12
+ PP_PATH_IOS=$RUNNER_TEMP/build_pp_ios.mobileprovision
+ PP_PATH_IOS_DEV=$RUNNER_TEMP/build_pp_ios_dev.mobileprovision
+ PP_PATH_MACOS=$RUNNER_TEMP/build_pp_macos.provisionprofile
+ 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
+ echo -n "$DEVELOPER_ID_INSTALLER_P12_BASE64_MAC" | base64 --decode --output $DEVELOPER_ID_INSTALLER_CERTIFICATE_PATH
+ echo -n "$APPSTORE_PROFILE_IOS_BASE64" | base64 --decode -o $PP_PATH_IOS
+ echo -n "$APPSTORE_PROFILE_DEV_IOS_BASE64" | base64 --decode -o $PP_PATH_IOS_DEV
+ echo -n "$APPSTORE_PROFILE_MACOS_BASE64" | base64 --decode -o $PP_PATH_MACOS
+
+ # 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 import $DEVELOPER_ID_INSTALLER_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
+
+ mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
+ cp $PP_PATH_IOS ~/Library/MobileDevice/Provisioning\ Profiles
+ cp $PP_PATH_IOS_DEV ~/Library/MobileDevice/Provisioning\ Profiles
+ cp $PP_PATH_MACOS ~/Library/MobileDevice/Provisioning\ Profiles
+
+ - name: Decode Keystore
+ run: |
+ echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/android.keystore;
+ echo "${{ secrets.KEYSTORE_PROPERTIES }}" > android/keystore.properties;
+
+ - name: 🚀 Shorebird Patch macOS
+ if: false # patch doesn't work: https://github.com/OpenBikeControl/bikecontrol/issues/143
+ uses: shorebirdtech/shorebird-patch@v1
+ with:
+ platform: macos
+ release-version: latest
+ args: '--allow-asset-diffs --allow-native-diffs --obfuscate --split-debug-info=symbols -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}'
+
+ - name: 🚀 Shorebird Patch Android
+ uses: shorebirdtech/shorebird-patch@v1
+ with:
+ platform: android
+ release-version: latest
+ args: '--allow-asset-diffs --allow-native-diffs --obfuscate --split-debug-info=symbols -- --dart-define=REVENUECAT_API_KEY_ANDROID=${{ secrets.REVENUECAT_API_KEY_ANDROID }} --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}'
+
+ - name: 🚀 Shorebird Patch iOS
+ uses: shorebirdtech/shorebird-patch@v1
+ with:
+ platform: ios
+ release-version: latest
+ args: '--allow-asset-diffs --allow-native-diffs --obfuscate --split-debug-info=symbols -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }} --dart-define=REVENUECAT_API_KEY_IOS=${{ secrets.REVENUECAT_API_KEY_IOS }}'
+
+ windows:
+ name: Patch Windows
+ runs-on: windows-latest
+
+ steps:
+ #1 Checkout Repository
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+ with:
+ submodules: recursive
+ token: ${{ secrets.PAT_TOKEN }}
+
+ - name: rename pubspec_overrides_ci.yaml to pubspec_overrides.yaml
+ shell: pwsh
+ run: |
+ if (Test-Path pubspec_overrides_ci.yaml) {
+ Rename-Item -Path pubspec_overrides_ci.yaml -NewName pubspec_overrides.yaml
+ } else {
+ Write-Output "No pubspec_overrides_ci.yaml found, skipping rename."
+ }
+
+ - name: 🐦 Setup Shorebird
+ uses: shorebirdtech/setup-shorebird@v1
+ with:
+ cache: true
+
+ - name: Set Up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: 'stable'
+ flutter-version: ${{ env.FLUTTER_VERSION }}
+
+ - name: Generate translation files
+ run: |
+ flutter pub global activate intl_utils;
+ flutter pub global run intl_utils:generate;
+
+ - name: 🚀 Shorebird Patch Windows
+ uses: shorebirdtech/shorebird-patch@v1
+ with:
+ platform: windows
+ release-version: latest
+ args: '--allow-asset-diffs --allow-native-diffs --obfuscate --split-debug-info=symbols -- --dart-define=SUPABASE_ANON_KEY=${{ secrets.SUPABASE_ANON_KEY }}'
diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml
new file mode 100644
index 000000000..8c24f4b9e
--- /dev/null
+++ b/.github/workflows/web.yml
@@ -0,0 +1,56 @@
+name: "Build Web"
+
+on:
+ push:
+ branches:
+ - web
+ - main
+ paths:
+ - '.github/workflows/web.yml'
+ - 'lib/**'
+ - 'accessibility/**'
+ - 'keypress_simulator/**'
+ - 'pubspec.yaml'
+
+jobs:
+ build:
+ name: Build Web
+ runs-on: macos-latest
+
+ permissions:
+ id-token: write
+ pages: write
+ contents: write
+
+ steps:
+ #1 Checkout Repository
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+
+ #3 Setup Flutter
+ - name: Set Up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ channel: 'stable'
+
+ - name: Generate translation files
+
+ run: |
+ flutter pub global activate intl_utils;
+ flutter pub global run intl_utils:generate;
+
+ #4 Install Dependencies
+ - name: Install Dependencies
+ run: flutter pub get
+
+ - name: Build Web
+ run: flutter build web --release --base-href "/bikecontrol/"
+
+ - name: Upload static files as artifact
+ id: deployment
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: build/web
+
+ - name: Web Deploy
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 79c113f9b..bd4c8ae90 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,8 +10,11 @@
.history
.svn/
.swiftpm/
+debug/
migrate_working_dir/
+android/keystore.properties
+
# IntelliJ related
*.iml
*.ipr
@@ -38,8 +41,19 @@ app.*.symbols
# Obfuscation related
app.*.map.json
+localazy.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+
+lib/gen/
+
+service-account.json
+.env
+lib/generated
+pubspec_overrides.yaml
+
+# FVM Version Cache
+.fvm/
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000..028d8e273
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "prop"]
+ path = prop
+ url = git@github.com:OpenBikeControl/prop.git
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 000000000..29dafdfc7
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,24 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+
+ {
+ "name": "swiftcontrol",
+ "request": "launch",
+ "type": "dart",
+ "program": "lib/main.dart"
+ },
+ {
+ "name": "swiftcontrol (profile mode)",
+ "request": "launch",
+ "type": "dart",
+ "flutterMode": "profile"
+ },
+ {
+ "name": "swiftcontrol (release mode)",
+ "request": "launch",
+ "type": "dart",
+ "flutterMode": "release"
+ }
+ ]
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..e69ed3592
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,305 @@
+### 5.0.0 (10-03-2026)
+
+BikeControl Pro is now available - subscribe to the Pro version to support the project and get access to additional features such as:
+- Cross Platform license: use the same subscription on all your devices (Windows, macOS, Android and iOS) without additional charge
+- Synchronize and backup your keymap settings across devices
+- Individual actions for single, long and double clicks, e.g. hold a button to steer, double click to perform a specific action. This means your two button devices can now have up to 6 different actions!
+- Windows, iOS, macOS: start any command/Shortcut, e.g. capture a screenshot, screen video, open a specific app, ... the possibilities are endless!
+- Windows, macOS: new screenshot action to capture a screenshot of the current screen and save it to your specified folder
+- Android: Open Assistant
+- and much more to come in the future!
+
+**Features**:
+- Bluetooth media buttons are now supported on iOS
+- Shimano Di2: long press and double clicks are now supported:
+ - perform steering using long presses
+ - gear changes are now reflected properly without losing any button presses
+- App is now available in Spanish
+
+**Fixes**:
+- macOS: Send keyboard key even if the trainer app isn't in foreground
+- You can now download BikeControl on Windows without the Microsoft Store
+
+### 4.7.0 (04-02-2026)
+
+**Features**:
+- new connection method: act as Bluetooth Keyboard:
+Your device can now act as Bluetooth keyboard, allowing you to send keyboard shortcuts (e.g. for virtual shifting) directly to your connected device. Especially useful for tablets / iPads.
+- added new keyboard shortcuts for Rouvy (Kudos, Pause workout)
+
+**Fixes**:
+- you can now finally buy the full version on Android :)
+- save "Enable Media Key detection" setting across app restarts
+- UI adjustments and fixes in the controller configuration screen
+- iOS: Remote pairing now works again
+
+### 4.6.0 (28-01-2026)
+
+**Features**:
+- Improve Zwift Click V2 connection and handling
+- Buttons in Configuration are now grouped by device
+
+### 4.5.0 (22-01-2026)
+
+**Features**:
+- Android: simulate additional actions for local connection method (Left, Down, Right, Up, Select, Back, Home, Recent Apps)
+ - control your phone with your controller
+ - control UI within the trainer app (if supported)
+- BikeControl now supports individual mapping when you use more than one Cycplus BC2 and ThinkRider VS200 controller
+- Windows & macOS: allow configuration of volume keys on Bluetooth HID devices
+
+### 4.4.0 (16-01-2026)
+
+**Features**:
+- Support for Thinkrider VS200
+
+**Fixes**:
+- Android: Local connection method allows passing keyboard events to the trainer app
+- macOS: Compatibility with macOS Tahoe
+- Windows: send keyboard events to the correct window when using multiple monitors or when another app is focused
+- Windows: fix media key detection
+
+### 4.3.0 (07-01-2026)
+
+**Features**:
+- Onboarding for new users
+- support controlling music & volume for Windows, macOS and Android
+- App is now available in Italian (thanks to Connect_Thanks2613)
+
+**Fixes**:
+- Vibration setting now available for Zwift Ride devices
+
+### 4.2.0 (20-12-2025)
+
+BikeControl now offers a free trial period of 5 days for all features, so you can test everything before deciding to purchase a license. Please contact the support if you experience any issues!
+
+**Features**:
+- support for SRAM AXS/eTap
+ - only single or double click is supported (no individual button mapping possible, yet)
+- use your phone/tablet for steering by attaching your device on your handlebar!
+- App is now available in Polish (thanks to Wandrocek)
+
+**Fixes**:
+- You will now be notified when a connection to your controller is lost
+- improved UI of the Keymap customization screen
+
+### 4.1.0 (16-12-2025)
+
+**Features**:
+- control your trainer manually without requiring a controller - just like a Companion app
+- support for Wahoo KICKR HEADWIND: control the fan via your controller
+
+**Fixes**:
+- Gamepads: handle analog values correctly on Windows
+- MyWhoosh: updated default keymap to use the new A+D keys for steering
+
+### 4.0.0 (07-12-2025)
+
+- a brand-new design
+ - Accessibility Permission is now optional on Android
+- Zwift is now fully supported on all operating systems
+ - you can choose between network based control or bluetooth based control
+- MyWhoosh can now also be controlled with BikeControl running on the same iPad / iPhone
+- Translations available in German and French
+- support for Wahoo KICKR BIKE PRO
+- support for the OpenBikeControl protocol for supported Trainer apps
+ - this enables seamless and official integration, independent of the operating system
+ - learn more at https://openbikecontrol.org
+
+### 3.6.0 (23-11-2025)
+
+SwiftControl is now called BikeControl!
+
+**Features:**
+- show a list of predefined keymaps for the selected trainer app when using a custom keymap
+- status icons so it's clear what's missing
+
+**Fixes:**
+- Update Rouvy keymap to support virtual shifting in their latest version
+
+### 3.5.0 (16-11-2025)
+**New Features:**
+- Dark mode support
+- Cycplus BC2 support (thanks @schneewoehner)
+- Ignored devices now persist across app restarts - remove them from ignored devices via the menu
+
+**Fixes:**
+- resolve issues during app start
+
+### 3.4.0 (08-11-2025)
+**New Features:**
+- Support for Shimano Di2
+- Support Keyboard shortcuts with modifier keys (Ctrl, Alt, Shift, ...)
+- Support cheap BLE HID remotes
+- add Keymap for Rouvy, supporting the new keyboard shortcuts for virtual shifting
+
+**Fixes:**
+- fix detection of Elite Square Sterzo devices
+- recognize cheap Bluetooth device clicks also when BikeControl is in the background
+
+### 3.3.0 (31-10-2025)
+
+**New Features:**
+- Support for Elite Sterzo (thanks @michidk)
+- Support for Gamepads
+- Support for cheap bluetooth remotes (such as [these](https://www.amazon.com/s?k=bluetooth+remote))
+- you can now customize the Keymap right from the Customize section
+- show signal strength of connected devices (thanks @michidk)
+- Android and Windows only: simulate bluetooth controllers
+ - enables gamepad and bluetooth remotes support for Zwift, Rouvy and Biketerra
+
+**Fixes:**
+- fix firmware version display for Zwift Click V2 devices
+- fix touch position on some Android devices
+- Wahoo Kickr Bike Shift can now be connected
+- update default keymap for TrainingPeaks
+
+### 3.2.0 (2025-10-22)
+- a brand-new way of controlling MyWhoosh:
+ - device pairing no longer required as mouse emulation is no longer needed
+ - BikeControl can now stay in the background
+ - more devices can be controlled
+ - do more, such as define Emotes, Camera angles and steering
+
+### 3.1.0 (2025-10-17)
+- new app icon
+- adjusted MyWhoosh keyboard navigation mapping (thanks @bin101)
+- support for Wahook Kickr Bike Shift (thanks @MattW2)
+- initial support for Elite Square Smart Frame
+- reconnects to your device automatically when connection is lost
+- BikeControl now warns you if your device firmware is outdated
+- BikeControl is now available in Microsoft Store: https://apps.microsoft.com/detail/9NP42GS03Z26
+
+### 3.0.3 (2025-10-12)
+- BikeControl now supports iOS!
+ - Note that you can't run BikeControl and your trainer app on the same iPhone due to iOS limitations but...:
+- You can now use BikeControl as "remote control" for other devices, such as an iPad. Example scenario:
+ - your phone (Android/iOS) runs BikeControl and connects to your Click devices
+ - your iPad or other tablet runs e.g. MyWhoosh (does not need to have BikeControl installed)
+ - after pairing BikeControl to your iPad / tablet via Bluetooth your phone will send the button presses to your iPad / tablet
+- Ride: analog paddles are now supported thanks to contributor @jmoro
+- you can now zoom in and out in the Keymap customization screen
+
+### 2.6.3 (2025-10-01)
+- fix a few issues with the new touch placement feature
+- add a workaround for Zwift Click V2 which resets the device when button events are no longer sent
+- fix issue on Android and Desktop where only a "touch down" was sent, but no "touch up"
+- improve UI when handling custom keymaps around the edges of the screen
+
+### 2.6.0 (2025-09-30)
+- refactor touch placements: show touches on screen, fix misplaced coordinates - should fix #64
+- show firmware version of connected device
+- Fix crashes on some Android devices
+- warn the user how to make Zwift Click V2 work properly
+- many UI improvements
+- add setting to enable or disable vibration on button press for Zwift Ride and Zwift Play controllers
+
+### 2.5.0 (2025-09-25)
+- Improve usability
+- BikeControl is now available via the Play Store: https://play.google.com/store/apps/details?id=de.jonasbark.swiftcontrol
+ - BikeControl will continue to be available to download for free on GitHub
+ - contact me if you already donated and I'll get a voucher for you :)
+
+### 2.4.0+1 (2025-09-17)
+- Windows: fix mouse clicks at wrong location due to display scaling (fixes #64)
+
+### 2.4.0 (2025-09-16)
+- Show an overview of the keymap bindings
+- Allow customizing an existing keymap
+- Add more donation options
+
+### 2.3.0 (2025-09-11)
+- Add support for latest Zwift Click v2
+
+### 2.2.0 (2025-09-08)
+- Add Long Press Mode option for custom keymaps - buttons can now send sustained key presses instead of repeated taps, perfect for movement controls in games (fixes #61)
+- Windows: adjust key sending method to improve compatibility with more apps (fixes #62)
+
+### 2.1.0 (2025-07-03)
+- Windows: automatically focus compatible training apps (MyWhoosh, IndieVelo, Biketerra) when sending keystrokes, enabling seamless multi-window usage
+
+### 2.0.9 (2025-05-04)
+- you can now assign Escape and arrow down key to your custom keymap (#18)
+
+### 2.0.8 (2025-05-02)
+- only use the light theme for the app
+- more troubleshooting information
+
+### 2.0.7 (2025-04-18)
+- add Biketerra.com keymap
+- some UX improvements
+
+### 2.0.6 (2025-04-15)
+- fix MyWhoosh up / downshift button assignment (I key vs K key)
+
+### 2.0.5 (2025-04-13)
+- fix Zwift Click button assignment (#12)
+
+### 2.0.4 (2025-04-10)
+- vibrate Zwift Play / Zwift Ride controller on gear shift (thanks @cagnulein, closes #16)
+
+### 2.0.3 (2025-04-08)
+- adjust TrainingPeaks Virtual key mapping (#12)
+- attempt to reconnect device if connection is lost
+- Android: detect freeform windows for MyWhoosh + TrainingPeaks Virtual keymaps
+
+### 2.0.2 (2025-04-07)
+- fix bluetooth scan issues on older Android devices by asking for location permission
+
+### 2.0.1 (2025-04-06)
+- long pressing a button will trigger the action again every 250ms
+
+### 2.0.0 (2025-04-06)
+- You can now customize the actions (touches, mouse clicks or keyboard keys) for all buttons on all supported Zwift devices
+- now shows the battery level of the connected devices
+- add more troubleshooting information
+
+### 1.1.10 (2025-04-03)
+- Add more troubleshooting during connection
+
+### 1.1.8 (2025-04-02)
+- Android: make sure the touch reassignment page is fullscreen
+
+### 1.1.7 (2025-04-01)
+- Zwift Ride: fix connection issues by connecting only to the left controller
+- Windows: connect sequentially to fix (finally?) fix connection issues
+- Windows: change the way keyboard is simulated, should fix glitches
+
+### 1.1.6 (2025-03-31)
+- Zwift Ride: add buttonPowerDown to shift gears
+- Zwift Play: Fix buttonShift assignment
+- Android: fix action to go to next song
+- App now checks if you run the latest available version
+
+### 1.1.5 (2025-03-30)
+- fix bluetooth connection #6, also add missing entitlement on macOS
+
+### 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)
diff --git a/INSTRUCTIONS_IOS.md b/INSTRUCTIONS_IOS.md
new file mode 100644
index 000000000..0d2c00cee
--- /dev/null
+++ b/INSTRUCTIONS_IOS.md
@@ -0,0 +1 @@
+Moved to [INSTRUCTIONS_MYWHOOSH_LINK.md](INSTRUCTIONS_MYWHOOSH_LINK.md)
diff --git a/INSTRUCTIONS_LOCAL.md b/INSTRUCTIONS_LOCAL.md
new file mode 100644
index 000000000..5ac2dd588
--- /dev/null
+++ b/INSTRUCTIONS_LOCAL.md
@@ -0,0 +1,17 @@
+## What is the Local connection method?
+*
+The Local connection method works by directly controlling the target trainer app on the same device by simulating user input (taps, keyboard inputs). This method does not require any network connection or additional hardware, making it the simplest and most straightforward way to connect to the trainer app.
+
+There are predefined keymaps (touch positions or keyboard shortcuts) for popular trainer apps, allowing users to easily set up and start using the Local connection method without needing to configure anything manually. You can configure these keymaps in the Configuration tab. Note though that supported keyboard keys depend on the trainer app.
+
+## When to use the Local connection method?
+*
+The Local connection method is ideal for users who:
+ - Are running the trainer app on the same device as the controller app (e.g., both apps on a smartphone or tablet).
+ - Do not want to deal with network configurations or potential connectivity issues.
+
+## Limitations of the Local connection method
+*
+While the Local connection method is easy to set up and use, it has some limitations:
+ - It may not work well with trainer apps that have complex user interfaces or require precise timing.
+ - It is limited to the device on which both the controller and trainer apps are running, meaning it cannot be used for remote control scenarios.
diff --git a/INSTRUCTIONS_MYWHOOSH_LINK.md b/INSTRUCTIONS_MYWHOOSH_LINK.md
new file mode 100644
index 000000000..9b50d5f90
--- /dev/null
+++ b/INSTRUCTIONS_MYWHOOSH_LINK.md
@@ -0,0 +1,39 @@
+## Instructions for using the MyWhoosh "Link" connection method
+*
+1) Launch MyWhoosh on the device of your choice
+2) Only needed once: open the "MyWhoosh Link" app on the same device where you want to use BikeControl. Make sure the Link app is able to connect to MyWhoosh. If it does, close MyWhoosh Link.
+3) Make sure the "MyWhoosh Link" app is not active at the same time as BikeControl
+4) Open BikeControl, enable the Link connection method, and follow the on-screen instructions
+
+
+Here's a video with a few explanations. Note that it uses an older version, but the idea is the same.
+
+[](https://www.youtube.com/watch?v=p8sgQhuufeI)
+[https://www.youtube.com/watch?v=p8sgQhuufeI](https://www.youtube.com/watch?v=p8sgQhuufeI)
+
+## MyWhoosh "Link" method never connects
+*
+This is a network/local-discovery problem. BikeControl needs the same kind of local network access as MyWhoosh Link.
+
+Checklist:
+- Use the MyWhoosh Link app to confirm if "Link" works in general
+- Use MyWhoosh Link app and connect, then close it, then open up BikeControl - this is key for some users
+- Both devices (if you use BikeControl on another device than MyWhoosh) are on the **same Wi‑Fi SSID**
+ - Avoid “Guest” networks
+ - Avoid “extenders/mesh guest mode” and networks with device isolation
+- If your router has it, disable:
+ - “AP isolation / client isolation”
+- Try moving both devices to the same band:
+ - Prefer **2.4 GHz** (often more reliable for local discovery than mixed/steering)
+- Temporarily disable:
+ - VPNs
+ - iCloud Private Relay (if enabled)
+ - “Limit IP Address Tracking” (iOS Wi‑Fi option)
+- iOS Wi‑Fi settings for that network:
+ - Turn off **Private Wi‑Fi Address**
+ - Turn off **Limit IP Address Tracking**
+- Mesh networks: may work, but if it doesn’t, test with a simple router or phone hotspot.
+
+Official MyWhoosh troubleshooting links:
+- https://mywhoosh.com/troubleshoot/
+- https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/
diff --git a/INSTRUCTIONS_REMOTE_CONTROL.md b/INSTRUCTIONS_REMOTE_CONTROL.md
new file mode 100644
index 000000000..2e1db963c
--- /dev/null
+++ b/INSTRUCTIONS_REMOTE_CONTROL.md
@@ -0,0 +1,13 @@
+## Remote control is not working - nothing happens
+*
+- Try to unpair it from your phone / computer Bluetooth settings, then re-pair it.
+- Try restarting the pairing process in BikeControl
+- try restarting Bluetooth on your phone and on the device you want to control
+- If your other device is an iOS device, go to Settings > Accessibility > Touch > AssistiveTouch > Pointer Devices > Devices and pair your device. Make sure AssistiveTouch is enabled.
+- it is very important that both devices (e.g. iPhone and iPad) receive the "pairing dialog" after initial connection. If you miss it, unpair and try again. It may take a few seconds for the dialog to appear. Afterwards you may need to click on "Reconnect" in BikeControl / restart BikeControl.
+
+## Remote control only clicks on a single coordinate on my iPad
+*
+iOS seems to be buggy here - try this in the iOS settings:
+AssistiveTouch settings > Pointer Devices > Devices > Connected Devices > iPhone (or BikeControl iOS) > Button 1
+switch the setting to None, then back to Single-Tap and it should work again
diff --git a/INSTRUCTIONS_ROUVY.md b/INSTRUCTIONS_ROUVY.md
new file mode 100644
index 000000000..47d20c81c
--- /dev/null
+++ b/INSTRUCTIONS_ROUVY.md
@@ -0,0 +1,4 @@
+## Local Connection method
+*
+The local connection method (avalable on Android, Windows and macOS) allows BikeControl to directly control Rouvy either using touch or keyboard keys. This way you don't need to select any "Controllers" at all in Rouvy.
+Make sure the "Virtual Shifting Controls" are enabled: https://support.rouvy.com/hc/en-us/articles/32452137189393-Virtual-Shifting#h_01K9SWGWYMAVQV108SQ9KWQAKC
diff --git a/INSTRUCTIONS_ZWIFT.md b/INSTRUCTIONS_ZWIFT.md
new file mode 100644
index 000000000..e69de29bb
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 000000000..181e74d8a
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,97 @@
+License notice:
+Versions of this project released prior to repository tag gpl3 were licensed
+under GPL-3.0. Those versions remain available under their original
+license. Versions released after that point are licensed under the
+Non-Commercial License.
+
+NON-COMMERCIAL SOFTWARE LICENSE AGREEMENT
+
+Version 1.0
+
+Copyright (c) 2026 OpenBikeControl UG (haftungsbeschränkt).
+All rights reserved.
+
+⸻
+
+1. Definitions
+
+“Software” means the source code, object code, binaries, and associated documentation made available by the Licensor under this License.
+
+“Commercial Use” means any use of the Software, directly or indirectly, that is intended for or results in:
+ • monetary compensation,
+ • sale, licensing, or subscription fees,
+ • advertising or sponsorship revenue,
+ • inclusion in a product or service that is sold or monetized,
+ • distribution through paid applications or application marketplaces.
+
+“Licensor” means the copyright holder.
+
+⸻
+
+2. Grant of License
+
+Subject to the terms of this License, the Licensor grants you a non-exclusive, non-transferable, revocable license to:
+ • use the Software for personal, educational, or internal evaluation purposes only;
+ • modify the Software for non-commercial purposes;
+ • redistribute the Software only in source form, free of charge, and only under this same License.
+
+⸻
+
+3. Restrictions
+
+You may not, without prior written permission from the Licensor:
+ • use the Software for any Commercial Use;
+ • distribute the Software as part of a paid or monetized product or service;
+ • distribute the Software via application marketplaces (including but not limited to Apple App Store or Google Play) where the application itself or related services are monetized;
+ • sublicense, sell, rent, or lease the Software.
+
+⸻
+
+4. Attribution
+
+All copies and derivative works must retain:
+ • this License text;
+ • all existing copyright notices.
+
+⸻
+
+5. No Patent License
+
+This License does not grant any patent rights.
+
+⸻
+
+6. Termination
+
+Any violation of this License automatically terminates your rights under this License.
+
+Upon termination, you must cease all use and distribution of the Software.
+
+⸻
+
+7. Disclaimer of Warranty
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND.
+
+⸻
+
+8. Limitation of Liability
+
+IN NO EVENT SHALL THE LICENSOR BE LIABLE FOR ANY DAMAGES ARISING FROM THE USE OF THE SOFTWARE.
+
+⸻
+
+9. Governing Law
+
+This License shall be governed by the laws of [YOUR COUNTRY], excluding conflict-of-law rules.
+
+⸻
+
+10. Commercial Licensing
+
+Commercial use is available under separate commercial license terms.
+Contact: jonas@openbikecontrol.org
+
+⸻
+
+End of License
diff --git a/README.md b/README.md
index ad14eda0d..8a23901d3 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,106 @@
-# SwiftControl
+# BikeControl (formerly SwiftControl)
-SwiftControl - Control your virtual riding
+
-## TODO
-- github pipelines
-- test Zwift Ride
-- implement more actions for Play + Ride
-- write readme with logo
-- shorebird?
+## Description
+
+With BikeControl you can **control your favorite trainer app** using your Zwift Click, Zwift Ride, Zwift Play, Shimano Di2, or other similar devices. Here's what you can do with it, depending on your configuration:
+- Virtual Gear shifting
+- Steering / navigation
+- adjust workout intensity
+- control music on your device
+- create screenshots or launch any command / shortcut
+- gestures: single click, double click, long press, etc. can be configured to do different things
+- more? If you can do it via keyboard, mouse, or touch, you can do it with BikeControl
+
+[](https://youtu.be/0r3LO5lFlyc)
+
+
+## Download
+Best follow our landing page and the "Get Started" button: [bikecontrol.app](https://bikecontrol.app/) to understand on which platform you want to run BikeControl. A testing period is available, allowing you to try out the full functionality of BikeControl:
+
+
+
+
+
+
+
+
+
+(or direct download for Windows [here](https://bikecontrol.app/download/bikecontrol.windows.zip))
+
+## Supported Apps
+- MyWhoosh
+- Zwift
+- TrainingPeaks Virtual
+- Biketerra.com
+- Rouvy
+- [OpenBikeControl](https://openbikecontrol.org) compatible apps
+- any other!
+ - You can add custom mapping and adjust touch points or keyboard shortcuts to your liking
+
+## Supported Devices
+- Zwift Click
+- Zwift Click v2 (mostly, see issue #68)
+- Zwift Ride
+- Zwift Play
+- Shimano Di2
+ - Configure your levers to use D-Fly channels with Shimano E-Tube app
+- SRAM AXS/eTap
+ - Configure your levers not to do any action in the "SRAM AXS" app
+ - only single or double click is supported (no individual button mapping possible, yet)
+- Wahoo Kickr Bike Shift
+- Wahoo Kickr Bike Pro
+- CYCPLUS BC2 Virtual Shifter
+- Thinkrider VS200 Virtual Shifter (beta)
+- Elite Sterzo Smart (for steering support)
+- Elite Square Smart Frame (beta)
+- Your Phone!
+ - Mount your phone on the handlebar to detect e.g. steering
+ - Available on Android and iOS
+- Gamepads
+- Keyboard input
+ - like a Companion App
+ - some trainers do not support keyboard input for all functions - now they do!
+ - useful when remapping keys from other devices using e.g. AutoHotkey
+- Cheap Bluetooth buttons such as [these](https://www.amazon.com/s?k=bluetooth+remote) (beta)
+ - BLE HID devices and classic Bluetooth HID devices are supported
+ - works out of the box on Android
+ - on Windows, iOS and macOS requires BikeControl to act as media player
+- We're working on creating an affordable alternative based on an open standard, supported by all major trainer apps
+ - register your interest [here](https://openbikecontrol.org/#HARDWARE)
+
+Support for other devices can be added; check the issues tab here on GitHub.
+
+## Supported Accessories
+- Wahoo KICKR HEADWIND (beta)
+ - control fan speed using your controller
+
+## Supported Platforms
+
+Follow the "Get Started" button over at [bikecontrol.app](https://bikecontrol.app) to understand on which platform you want to run BikeControl.
+You can even try it out in your [Browser](https://openbikecontrol.github.io/bikecontrol/), if it supports Bluetooth connections. No controlling possible, though.
+
+## Help
+Check the troubleshooting guide [here](TROUBLESHOOTING.md).
+
+## How does it work?
+The app connects to your Controller devices (such as Zwift ones) automatically. BikeControl uses different methods of connecting to the trainer app, depending on the trainer app and operating system:
+- Connect to the trainer app on the same device or on another device using Network
+ - available on Android, iOS, iPadOS, macOS, Windows
+ - supported by e.g. MyWhoosh, Rouvy and Zwift
+- Connect to the trainer app on another device by simulating a Bluetooth device
+ - available on Android, iOS, iPadOS, macOS, Windows
+ - supported by e.g. Rouvy and Zwift
+- Directly control the trainer app via Accessibility features (simulating touch and keyboard input)
+ - available on Android, macOS, Windows
+ - supported by all trainer apps
+- Connect to the supported trainer app using the [OpenBikeControl](https://openbikecontrol.org) protocol
+ - available on Android, iOS, iPadOS, macOS, Windows
+
+## Donate
+Please consider donating to support the development of this app :)
+
+- [via PayPal](https://paypal.me/boni)
+- [via Credit Card, Google Pay, Apple Pay, etc. (USD)](https://donate.stripe.com/8x24gzc5c4ZE3VJdt36J201)
+- [via Credit Card, Google Pay, Apple Pay, etc. (EUR)](https://donate.stripe.com/9B6aEX0muajY8bZ1Kl6J200)
diff --git a/SCRIPTING.md b/SCRIPTING.md
new file mode 100644
index 000000000..150698186
--- /dev/null
+++ b/SCRIPTING.md
@@ -0,0 +1,52 @@
+# Scripting Guide (Experimental)
+
+## What It Does
+You can attach a custom Dart script to each controller device type (for example `ZwiftRide` or `ZwiftClickV2`).
+When the device receives a BLE value update, BikeControl can run your script and write a new BLE payload based on the script output.
+
+## Where To Find It
+1. Look for the device in the devices list.
+2. Click the menu (same menu that contains `Disconnect and Forget`).
+3. Click `Run Script`.
+
+## Script Storage
+- Scripts are saved to a file per **device class/type**.
+- Example: one script file for `ZwiftRide`, another for `ZwiftClickV2`.
+- If no script file exists yet, the editor is prefilled with the default script from `lib/utils/interpreter.dart`.
+
+## Required Signature
+Your script must expose a top-level `main` function with:
+
+```dart
+Future> main(String characteristicUuid, List data) async {
+ return [characteristicUuid, data];
+}
+```
+
+Validation checks on Save:
+- A top-level `main` function exists.
+- `main` has exactly 2 positional parameters.
+- `main` returns `List<...>` or `Future>`.
+
+## Output Contract
+`main(...)` must return a list with 0 items (nothing should happen), or 2 items (send a write command):
+1. `String` characteristic UUID to write to.
+2. `List` byte payload (values must be in `0..255`).
+
+Example:
+
+```dart
+Future> main(String characteristicUuid, List data) async {
+ final mirrored = data.reversed.toList();
+ return [characteristicUuid, mirrored];
+}
+```
+
+## Runtime Behavior
+- Your script runs when a value from any characteristic is received for a device of the corresponding type.
+- If the script exists for that device type, BikeControl uses the output and writes the value to the characteristic.
+- If the returned characteristic does not exist on the connected device, the write is skipped and a log entry is added.
+
+## Notes
+- This is experimental and runs on every value update; keep scripts lightweight.
+- Invalid scripts are rejected on Save and are not written to disk.
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
new file mode 100644
index 000000000..a265c4129
--- /dev/null
+++ b/TROUBLESHOOTING.md
@@ -0,0 +1,44 @@
+## Click / Ride device cannot be found
+*
+This means BikeControl does NOT see the device via Bluetooth.
+- Put the controller into pairing mode (LED should blink)
+- Ensure the controller is NOT connected to another app/device (e.g. Zwift)
+- Update controller firmware in Zwift Companion, if available
+- Reboot Bluetooth / reboot phone/PC
+
+## Click / Ride device does not send any data
+*
+You may need to update the firmware in Zwift Companion app.
+
+## My Click v2 disconnects after a minute or buttons do not work
+*
+
+To make your Click V2 work best you should connect it in the Zwift app once before a workout session.
+If you don't do that BikeControl will need to reconnect every minute.
+
+You have two possibilities:
+# BikeControl method
+Click on the "Unlock" button in BikeControl in the Devices tab, then follow on-screen instructions:
+[https://youtube.com/shorts/bSQKnpHnWCo?feature=share](https://youtube.com/shorts/bSQKnpHnWCo?feature=share)
+
+# Manual method
+1. Open Zwift app (not the Companion)
+2. Log in (subscription not required) → device connection screen
+3. Connect trainer, then connect Click v2
+4. Keep it connected for ~10–30 seconds
+5. Close Zwift completely, then connect in BikeControl
+
+Details/updates: [GitHub issue](https://github.com/OpenBikeControl/bikecontrol/issues/68)
+
+## Android: Connection works, buttons work but nothing happens in MyWhoosh and similar
+*
+- especially for Redmi and other chinese Android devices please follow the instructions on [https://dontkillmyapp.com/](https://dontkillmyapp.com/):
+ - disable battery optimization for BikeControl
+ - enable auto start of BikeControl
+ - grant accessibility permission for BikeControl
+- see [https://github.com/OpenBikeControl/bikecontrol/issues/38](https://github.com/OpenBikeControl/bikecontrol/issues/38) for more details
+
+
+## My Clicks do not get recognized in MyWhoosh, but I am connected / use local control
+*
+Make sure you've enabled Virtual Shifting in MyWhoosh's settings
diff --git a/WINDOWS_STORE_VERSION.txt b/WINDOWS_STORE_VERSION.txt
new file mode 100644
index 000000000..af9764a59
--- /dev/null
+++ b/WINDOWS_STORE_VERSION.txt
@@ -0,0 +1 @@
+4.7.2
diff --git a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityApi.kt b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityApi.kt
index 0e4ece181..0bc563a1f 100644
--- a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityApi.kt
+++ b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityApi.kt
@@ -1,4 +1,4 @@
-// Autogenerated from Pigeon (v25.2.0), do not edit directly.
+// Autogenerated from Pigeon (v25.5.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
@@ -12,25 +12,57 @@ import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
+private object AccessibilityApiPigeonUtils {
-private fun wrapResult(result: Any?): List {
- return listOf(result)
-}
+ fun wrapResult(result: Any?): List {
+ return listOf(result)
+ }
-private fun wrapError(exception: Throwable): List {
- return if (exception is FlutterError) {
- listOf(
- exception.code,
- exception.message,
- exception.details
- )
- } else {
- listOf(
- exception.javaClass.simpleName,
- exception.toString(),
- "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
- )
+ fun wrapError(exception: Throwable): List {
+ return if (exception is FlutterError) {
+ listOf(
+ exception.code,
+ exception.message,
+ exception.details
+ )
+ } else {
+ listOf(
+ exception.javaClass.simpleName,
+ exception.toString(),
+ "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
+ )
+ }
}
+ fun deepEquals(a: Any?, b: Any?): Boolean {
+ if (a is ByteArray && b is ByteArray) {
+ return a.contentEquals(b)
+ }
+ if (a is IntArray && b is IntArray) {
+ return a.contentEquals(b)
+ }
+ if (a is LongArray && b is LongArray) {
+ return a.contentEquals(b)
+ }
+ if (a is DoubleArray && b is DoubleArray) {
+ return a.contentEquals(b)
+ }
+ if (a is Array<*> && b is Array<*>) {
+ return a.size == b.size &&
+ a.indices.all{ deepEquals(a[it], b[it]) }
+ }
+ if (a is List<*> && b is List<*>) {
+ return a.size == b.size &&
+ a.indices.all{ deepEquals(a[it], b[it]) }
+ }
+ if (a is Map<*, *> && b is Map<*, *>) {
+ return a.size == b.size && a.all {
+ (b as Map).containsKey(it.key) &&
+ deepEquals(it.value, b[it.key])
+ }
+ }
+ return a == b
+ }
+
}
/**
@@ -45,26 +77,62 @@ 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 }
+ }
+ }
+}
+
+enum class GlobalAction(val raw: Int) {
+ BACK(0),
+ DPAD_CENTER(1),
+ DOWN(2),
+ RIGHT(3),
+ UP(4),
+ LEFT(5),
+ HOME(6),
+ RECENTS(7);
+
+ companion object {
+ fun ofRaw(raw: Int): GlobalAction? {
+ return values().firstOrNull { it.raw == raw }
+ }
+ }
+}
+
/** Generated class from Pigeon that represents data sent in messages. */
data class WindowEvent (
val packageName: String,
- val windowHeight: Long,
- val windowWidth: Long
+ val top: Long,
+ val bottom: Long,
+ val right: Long,
+ val left: Long
)
{
companion object {
fun fromList(pigeonVar_list: List): WindowEvent {
val packageName = pigeonVar_list[0] as String
- val windowHeight = pigeonVar_list[1] as Long
- val windowWidth = pigeonVar_list[2] as Long
- return WindowEvent(packageName, windowHeight, windowWidth)
+ val top = pigeonVar_list[1] as Long
+ val bottom = pigeonVar_list[2] as Long
+ val right = pigeonVar_list[3] as Long
+ val left = pigeonVar_list[4] as Long
+ return WindowEvent(packageName, top, bottom, right, left)
}
}
fun toList(): List {
return listOf(
packageName,
- windowHeight,
- windowWidth,
+ top,
+ bottom,
+ right,
+ left,
)
}
override fun equals(other: Any?): Boolean {
@@ -74,10 +142,44 @@ data class WindowEvent (
if (this === other) {
return true
}
- return packageName == other.packageName
- && windowHeight == other.windowHeight
- && windowWidth == other.windowWidth
+ return AccessibilityApiPigeonUtils.deepEquals(toList(), other.toList()) }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
+
+/** Generated class from Pigeon that represents data sent in messages. */
+data class AKeyEvent (
+ val source: String,
+ val hidKey: String,
+ val keyDown: Boolean,
+ val keyUp: Boolean
+)
+ {
+ companion object {
+ fun fromList(pigeonVar_list: List): AKeyEvent {
+ val source = pigeonVar_list[0] as String
+ val hidKey = pigeonVar_list[1] as String
+ val keyDown = pigeonVar_list[2] as Boolean
+ val keyUp = pigeonVar_list[3] as Boolean
+ return AKeyEvent(source, hidKey, keyDown, keyUp)
+ }
}
+ fun toList(): List {
+ return listOf(
+ source,
+ hidKey,
+ keyDown,
+ keyUp,
+ )
+ }
+ override fun equals(other: Any?): Boolean {
+ if (other !is AKeyEvent) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return AccessibilityApiPigeonUtils.deepEquals(toList(), other.toList()) }
override fun hashCode(): Int = toList().hashCode()
}
@@ -85,17 +187,44 @@ 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 Long?)?.let {
+ GlobalAction.ofRaw(it.toInt())
+ }
+ }
+ 131.toByte() -> {
return (readValue(buffer) as? List)?.let {
WindowEvent.fromList(it)
}
}
+ 132.toByte() -> {
+ return (readValue(buffer) as? List)?.let {
+ AKeyEvent.fromList(it)
+ }
+ }
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
- is WindowEvent -> {
+ is MediaAction -> {
stream.write(129)
+ writeValue(stream, value.raw)
+ }
+ is GlobalAction -> {
+ stream.write(130)
+ writeValue(stream, value.raw)
+ }
+ is WindowEvent -> {
+ stream.write(131)
+ writeValue(stream, value.toList())
+ }
+ is AKeyEvent -> {
+ stream.write(132)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
@@ -109,7 +238,12 @@ val AccessibilityApiPigeonMethodCodec = StandardMethodCodec(AccessibilityApiPige
interface Accessibility {
fun hasPermission(): Boolean
fun openPermissions()
- fun performTouch(x: Double, y: Double)
+ fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
+ fun performGlobalAction(action: GlobalAction)
+ fun controlMedia(action: MediaAction)
+ fun isRunning(): Boolean
+ fun ignoreHidDevices()
+ fun setHandledKeys(keys: List)
companion object {
/** The codec used by Accessibility. */
@@ -127,7 +261,7 @@ interface Accessibility {
val wrapped: List = try {
listOf(api.hasPermission())
} catch (exception: Throwable) {
- wrapError(exception)
+ AccessibilityApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
@@ -143,7 +277,7 @@ interface Accessibility {
api.openPermissions()
listOf(null)
} catch (exception: Throwable) {
- wrapError(exception)
+ AccessibilityApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
@@ -158,11 +292,98 @@ interface Accessibility {
val args = message as List
val xArg = args[0] as Double
val yArg = args[1] as Double
+ val isKeyDownArg = args[2] as Boolean
+ val isKeyUpArg = args[3] as Boolean
val wrapped: List = try {
- api.performTouch(xArg, yArg)
+ api.performTouch(xArg, yArg, isKeyDownArg, isKeyUpArg)
listOf(null)
} catch (exception: Throwable) {
- wrapError(exception)
+ AccessibilityApiPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.performGlobalAction$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val actionArg = args[0] as GlobalAction
+ val wrapped: List = try {
+ api.performGlobalAction(actionArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ AccessibilityApiPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.controlMedia$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val actionArg = args[0] as MediaAction
+ val wrapped: List = try {
+ api.controlMedia(actionArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ AccessibilityApiPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.isRunning$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ listOf(api.isRunning())
+ } catch (exception: Throwable) {
+ AccessibilityApiPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.ignoreHidDevices$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List = try {
+ api.ignoreHidDevices()
+ listOf(null)
+ } catch (exception: Throwable) {
+ AccessibilityApiPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.accessibility.Accessibility.setHandledKeys$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val keysArg = args[0] as List
+ val wrapped: List = try {
+ api.setHandledKeys(keysArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ AccessibilityApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
@@ -223,3 +444,16 @@ abstract class StreamEventsStreamHandler : AccessibilityApiPigeonEventChannelWra
}
}
+abstract class HidKeyPressedStreamHandler : AccessibilityApiPigeonEventChannelWrapper {
+ companion object {
+ fun register(messenger: BinaryMessenger, streamHandler: HidKeyPressedStreamHandler, instanceName: String = "") {
+ var channelName: String = "dev.flutter.pigeon.accessibility.EventChannelMethods.hidKeyPressed"
+ if (instanceName.isNotEmpty()) {
+ channelName += ".$instanceName"
+ }
+ val internalStreamHandler = AccessibilityApiPigeonStreamHandler(streamHandler)
+ EventChannel(messenger, channelName, AccessibilityApiPigeonMethodCodec).setStreamHandler(internalStreamHandler)
+ }
+ }
+}
+
diff --git a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityPlugin.kt b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityPlugin.kt
index de02f0154..0117a227e 100644
--- a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityPlugin.kt
+++ b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityPlugin.kt
@@ -1,48 +1,47 @@
package de.jonasbark.accessibility
+import AKeyEvent
import Accessibility
+import GlobalAction
+import HidKeyPressedStreamHandler
+import MediaAction
import PigeonEventSink
import StreamEventsStreamHandler
import WindowEvent
import android.content.Context
import android.content.Intent
+import android.graphics.Rect
import android.os.Bundle
import android.provider.Settings
+import android.view.KeyEvent
import androidx.core.content.ContextCompat.startActivity
import io.flutter.embedding.engine.plugins.FlutterPlugin
-import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
-import io.flutter.plugin.common.MethodChannel.MethodCallHandler
-import io.flutter.plugin.common.MethodChannel.Result
/** AccessibilityPlugin */
-class AccessibilityPlugin: FlutterPlugin, MethodCallHandler, Accessibility {
+class AccessibilityPlugin: FlutterPlugin, Accessibility {
/// The MethodChannel that will the communication between Flutter and native Android
///
/// This local reference serves to register the plugin with the Flutter Engine and unregister it
/// when the Flutter Engine is detached from the Activity
private lateinit var channel : MethodChannel
private lateinit var context: Context
- private lateinit var eventHandler: EventListener
+ private lateinit var windowEventHandler: WindowEventListener
+ private lateinit var hidEventHandler: HidEventListener
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "accessibility")
- eventHandler = EventListener()
+ windowEventHandler = WindowEventListener()
+ hidEventHandler = HidEventListener()
context = flutterPluginBinding.applicationContext
Accessibility.setUp(flutterPluginBinding.binaryMessenger, this)
- StreamEventsStreamHandler.register(flutterPluginBinding.binaryMessenger, eventHandler)
- Observable.fromService = eventHandler
- }
-
- override fun onMethodCall(call: MethodCall, result: Result) {
- if (call.method == "getPlatformVersion") {
- result.success("Android ${android.os.Build.VERSION.RELEASE}")
- } else {
- result.notImplemented()
- }
+ StreamEventsStreamHandler.register(flutterPluginBinding.binaryMessenger, windowEventHandler)
+ HidKeyPressedStreamHandler.register(flutterPluginBinding.binaryMessenger, hidEventHandler)
+ Observable.fromServiceWindow = windowEventHandler
+ Observable.fromServiceKeys = hidEventHandler
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -54,32 +53,99 @@ class AccessibilityPlugin: FlutterPlugin, MethodCallHandler, Accessibility {
return enabledServices != null && enabledServices.contains(context.packageName)
}
+ override fun isRunning(): Boolean {
+ return Observable.toService != null
+ }
+
override fun openPermissions() {
startActivity(context, Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}, Bundle.EMPTY)
}
- override fun performTouch(x: Double, y: Double) {
- Observable.toService?.performTouch(x = x, y = y) ?: error("Service not running")
+ override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
+ Observable.toService?.performTouch(x = x, y = y, isKeyUp = isKeyUp, isKeyDown = isKeyDown) ?: error("Service not running")
+ }
+
+ override fun performGlobalAction(action: GlobalAction) {
+ Observable.toService?.performGlobalAction(action) ?: 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(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
+ audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE))
+ }
+ MediaAction.NEXT -> {
+ audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT))
+ audioService.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, 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)
+ }
+ }
+ }
+
+ override fun ignoreHidDevices() {
+ Observable.ignoreHidDevices = true
+ }
+
+ override fun setHandledKeys(keys: List) {
+ // Clear and update the concurrent set
+ Observable.handledKeys = keys.toSet()
}
}
-class EventListener : StreamEventsStreamHandler(), Receiver {
+class WindowEventListener : StreamEventsStreamHandler(), Receiver {
private var eventSink: PigeonEventSink? = null
override fun onListen(p0: Any?, sink: PigeonEventSink) {
eventSink = sink
}
- fun onEventsDone() {
+ override fun onCancel(p0: Any?) {
eventSink?.endOfStream()
eventSink = null
}
- override fun onChange(packageName: String, windowWidth: Int, windowHeight: Int) {
- eventSink?.success(WindowEvent(packageName = packageName, windowWidth = windowWidth.toLong(), windowHeight = windowHeight.toLong()))
+ override fun onChange(packageName: String, window: Rect) {
+ eventSink?.success(WindowEvent(packageName = packageName, right = window.right.toLong(), left = window.left.toLong(), bottom = window.bottom.toLong(), top = window.top.toLong()))
}
+ override fun onKeyEvent(event: KeyEvent) {
+
+ }
+
+}
+
+
+class HidEventListener : HidKeyPressedStreamHandler(), Receiver {
+
+ private var keyEventSink: PigeonEventSink? = null
+
+ override fun onListen(p0: Any?, sink: PigeonEventSink) {
+ keyEventSink = sink
+ }
+
+ override fun onChange(packageName: String, window: Rect) {
+
+ }
+
+ override fun onKeyEvent(event: KeyEvent) {
+ val keyString = KeyEvent.keyCodeToString(event.keyCode)
+ keyEventSink?.success(
+ AKeyEvent(
+ hidKey = keyString,
+ source = event.device.name,
+ keyUp = event.action == KeyEvent.ACTION_UP,
+ keyDown = event.action == KeyEvent.ACTION_DOWN
+ )
+ )
+ }
}
diff --git a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityService.kt b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityService.kt
index c5b8632fe..35d050d28 100644
--- a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityService.kt
+++ b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/AccessibilityService.kt
@@ -3,14 +3,19 @@ package de.jonasbark.accessibility
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.accessibilityservice.GestureDescription.StrokeDescription
+import android.accessibilityservice.AccessibilityServiceInfo
+import android.content.Context
import android.graphics.Path
import android.graphics.Rect
+import android.media.AudioManager
import android.os.Build
import android.util.Log
+import android.view.InputDevice
+import android.view.KeyEvent
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
+import GlobalAction
class AccessibilityService : AccessibilityService(), Listener {
@@ -26,46 +31,101 @@ 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
}
val currentPackageName = event.packageName.toString()
val windowSize = getWindowSize()
- Observable.fromService?.onChange(packageName = currentPackageName, windowHeight = windowSize.bottom, windowWidth = windowSize.right)
+ Observable.fromServiceWindow?.onChange(packageName = currentPackageName, window = windowSize)
}
private fun getWindowSize(): Rect {
val outBounds = Rect()
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- rootInActiveWindow.getBoundsInWindow(outBounds)
+ rootInActiveWindow?.getBoundsInScreen(outBounds)
+ return outBounds
+ }
+
+
+ override fun onInterrupt() {
+ Log.d("AccessibilityService", "Service Interrupted")
+ }
+
+ override fun onServiceConnected() {
+ super.onServiceConnected()
+ // Request key event filtering so we receive onKeyEvent for hardware/HID media keys
+ try {
+ val info = serviceInfo ?: AccessibilityServiceInfo()
+ info.flags = info.flags or AccessibilityServiceInfo.FLAG_REQUEST_FILTER_KEY_EVENTS
+ // keep other capabilities as defined in XML
+ setServiceInfo(info)
+ } catch (e: Exception) {
+ Log.w("AccessibilityService", "Failed to set service info for key events: ${e.message}")
+ }
+ }
+
+ override fun onKeyEvent(event: KeyEvent): Boolean {
+ val keyString = KeyEvent.keyCodeToString(event.keyCode)
+ // if currently active app is BikeControl => handle it, so keymap can be created
+ if (!Observable.ignoreHidDevices && isBleRemote(event) && (rootInActiveWindow?.packageName == "de.jonasbark.swiftcontrol" || Observable.handledKeys.contains(keyString))) {
+ // Handle keys that have a keymap defined
+ Log.d(
+ "AccessibilityService",
+ "onKeyEvent: keyCode=${event.keyCode} action=${event.action} scanCode=${event.scanCode} flags=${event.flags}"
+ )
+
+ // Forward key events to the plugin (Flutter) and swallow them so they don't propagate.
+ Observable.fromServiceKeys?.onKeyEvent(event)
+ // Return true to indicate we've handled the event and it should be swallowed.
+ return true
} else {
- rootInActiveWindow.getBoundsInScreen(outBounds)
+ return false
}
- return outBounds
}
- private fun simulateTap(x: Double, y: Double) {
+ private fun isBleRemote(event: KeyEvent): Boolean {
+ val dev = InputDevice.getDevice(event.deviceId) ?: return false
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ dev.isExternal
+ } else {
+ true
+ }
+ }
+
+ override fun performGlobalAction(action: GlobalAction) {
+ val mappedAction = when (action) {
+ GlobalAction.BACK -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_BACK
+ GlobalAction.DPAD_CENTER -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_CENTER
+ GlobalAction.DOWN -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_DOWN
+ GlobalAction.RIGHT -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_RIGHT
+ GlobalAction.UP -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_UP
+ GlobalAction.LEFT -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_DPAD_LEFT
+ GlobalAction.HOME -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_HOME
+ GlobalAction.RECENTS -> android.accessibilityservice.AccessibilityService.GLOBAL_ACTION_RECENTS
+ }
+ performGlobalAction(mappedAction)
+ }
+
+ override fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean) {
val gestureBuilder = GestureDescription.Builder()
val path = Path()
path.moveTo(x.toFloat(), y.toFloat())
path.lineTo(x.toFloat()+1, y.toFloat())
- val stroke = StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong())
+ val stroke = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ StrokeDescription(path, 0, ViewConfiguration.getTapTimeout().toLong(), isKeyDown && !isKeyUp)
+ } else {
+ // API 24–25: no “willContinue” support
+ StrokeDescription(path, 0L, ViewConfiguration.getTapTimeout().toLong())
+ }
gestureBuilder.addStroke(stroke)
dispatchGesture(gestureBuilder.build(), null, null)
}
-
- override fun onInterrupt() {
- Log.d("AccessibilityService", "Service Interrupted")
- }
-
- override fun performTouch(x: Double, y: Double) {
- simulateTap(x, y)
- }
}
diff --git a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/Listener.kt b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/Listener.kt
index 95ce4b0f6..a92a8e99f 100644
--- a/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/Listener.kt
+++ b/accessibility/android/src/main/kotlin/de/jonasbark/accessibility/Listener.kt
@@ -1,14 +1,25 @@
package de.jonasbark.accessibility
+import android.graphics.Rect
+import android.view.KeyEvent
+import GlobalAction
+import java.util.concurrent.ConcurrentHashMap
+
object Observable {
var toService: Listener? = null
- var fromService: Receiver? = null
+ var fromServiceWindow: Receiver? = null
+ var fromServiceKeys: Receiver? = null
+ var ignoreHidDevices: Boolean = false
+ // Use concurrent set for thread-safe access from AccessibilityService and plugin
+ var handledKeys: Set = ConcurrentHashMap.newKeySet()
}
interface Listener {
- fun performTouch(x: Double, y: Double)
+ fun performTouch(x: Double, y: Double, isKeyDown: Boolean, isKeyUp: Boolean)
+ fun performGlobalAction(action: GlobalAction)
}
interface Receiver {
- fun onChange(packageName: String, windowWidth: Int, windowHeight: Int)
-}
\ No newline at end of file
+ fun onChange(packageName: String, window: Rect)
+ fun onKeyEvent(event: KeyEvent)
+}
diff --git a/accessibility/api.dart b/accessibility/api.dart
index 62e9e5518..b10cf037c 100644
--- a/accessibility/api.dart
+++ b/accessibility/api.dart
@@ -6,18 +6,59 @@ abstract class Accessibility {
void openPermissions();
- void performTouch(double x, double y);
+ void performTouch(double x, double y, {bool isKeyDown = true, bool isKeyUp = false});
+
+ void performGlobalAction(GlobalAction action);
+
+ void controlMedia(MediaAction action);
+
+ bool isRunning();
+
+ void ignoreHidDevices();
+
+ void setHandledKeys(List keys);
+}
+
+enum MediaAction { playPause, next, volumeUp, volumeDown }
+
+enum GlobalAction {
+ back,
+ dpadCenter,
+ down,
+ right,
+ up,
+ left,
+ home,
+ recents,
}
class WindowEvent {
final String packageName;
- final int windowHeight;
- final int windowWidth;
+ final int top;
+ final int bottom;
+ final int right;
+ final int left;
+
+ WindowEvent({
+ required this.packageName,
+ required this.left,
+ required this.right,
+ required this.top,
+ required this.bottom,
+ });
+}
+
+class AKeyEvent {
+ final String source;
+ final String hidKey;
+ final bool keyDown;
+ final bool keyUp;
- WindowEvent({required this.packageName, required this.windowHeight, required this.windowWidth});
+ AKeyEvent({required this.source, required this.hidKey, required this.keyDown, required this.keyUp});
}
@EventChannelApi()
abstract class EventChannelMethods {
WindowEvent streamEvents();
+ AKeyEvent hidKeyPressed();
}
diff --git a/accessibility/lib/accessibility.dart b/accessibility/lib/accessibility.dart
index e0ca46f86..63df71f66 100644
--- a/accessibility/lib/accessibility.dart
+++ b/accessibility/lib/accessibility.dart
@@ -1,4 +1,4 @@
-// Autogenerated from Pigeon (v25.2.0), do not edit directly.
+// Autogenerated from Pigeon (v25.5.0), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
@@ -14,25 +14,65 @@ PlatformException _createConnectionError(String channelName) {
message: 'Unable to establish connection on channel: "$channelName".',
);
}
+bool _deepEquals(Object? a, Object? b) {
+ if (a is List && b is List) {
+ return a.length == b.length &&
+ a.indexed
+ .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
+ }
+ if (a is Map && b is Map) {
+ return a.length == b.length && a.entries.every((MapEntry