Compare commits

...

22 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
d53a1905b1 Create base classes MdnsEmulator and BluetoothEmulator with shared logic
Co-authored-by: jonasbark <1151304+jonasbark@users.noreply.github.com>
2025-12-24 08:48:04 +00:00
copilot-swe-agent[bot]
8c689cbdb1 Initial plan 2025-12-24 08:35:45 +00:00
Jonas Bark
a487539e6a hotfix 2025-12-22 20:37:44 +01:00
Jonas Bark
ad7454236a hotfix 2025-12-22 20:17:27 +01:00
Jonas Bark
e182fea4d1 hotfix 2025-12-22 20:05:09 +01:00
Jonas Bark
6615061658 hotfix macOS 2025-12-22 20:00:17 +01:00
jonasbark
5c1d423806 Update CHANGELOG.md 2025-12-22 19:34:37 +01:00
Jonas Bark
171f97645f Merge branch 'main' of github.com:jonasbark/swiftcontrol 2025-12-22 19:24:14 +01:00
jonasbark
9511c233a2 Add iPadOS support to connection methods in README
Updated README to include iPadOS in connection methods.
2025-12-22 18:23:47 +01:00
jonasbark
adfc2bd8cf Update Windows Store version to 4.2.2 2025-12-22 15:29:26 +01:00
jonasbark
2adba27dca Fix formatting and clarify trainer app connection 2025-12-22 11:41:41 +01:00
Jonas Bark
b18c85fc8e improve Android notification action handling 2025-12-22 11:13:37 +01:00
Jonas Bark
2e79a43827 axs info 2025-12-22 08:21:02 +01:00
Jonas Bark
0fec34bb56 allow redeeming manually 2025-12-21 22:21:40 +01:00
Jonas Bark
db3e133199 allow redeeming manually 2025-12-21 22:17:47 +01:00
Jonas Bark
971cb91615 allow redeeming manually 2025-12-21 22:16:12 +01:00
Jonas Bark
e5e04f3d59 clarify Android transition 2025-12-21 20:33:51 +01:00
Jonas Bark
fd9f7388e8 check purchases once a day 2025-12-21 16:41:30 +01:00
Jonas Bark
6ae2297246 version++ 2025-12-21 16:00:10 +01:00
Jonas Bark
c6fb2e68b5 win fix 2025-12-21 15:43:44 +01:00
jonas.bark@gmx.de
c84c685a8f attempt to fix remaining trial days calculation 2025-12-21 15:42:17 +01:00
Jonas Bark
102f4a8818 add info for Android users regarding existing purchase 2025-12-21 13:54:08 +01:00
30 changed files with 875 additions and 422 deletions

View File

@@ -119,7 +119,7 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: macos
args: "--dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }}"
args: "-- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }}"
- name: Decode Keystore
if: inputs.build_android
@@ -169,7 +169,7 @@ jobs:
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
platform: ios
args: "--export-options-plist ios/ExportOptions.plist --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }}"
args: "--export-options-plist ios/ExportOptions.plist -- --dart-define=VERIFYING_SHARED_SECRET=${{ secrets.VERIFYING_SHARED_SECRET }}"
- name: Prepare App Store authentication key
if: inputs.build_ios || inputs.build_mac

View File

@@ -6,7 +6,7 @@ BikeControl now offers a free trial period of 5 days for all features, so you ca
- 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 Wandrocet)
- App is now available in Polish (thanks to Wandrocek)
**Fixes**:
- You will now be notified when a connection to your controller is lost

View File

@@ -46,6 +46,7 @@ Best follow our landing page and the "Get Started" button: [bikecontrol.app](htt
- 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
@@ -84,16 +85,16 @@ 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, macOS, Windows
- 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, macOS, Windows
- 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 supported trainer app using the [OpenBikeControl](https://openbikecontrol.org) protocol
- available on Android, iOS, macOS, Windows
- 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 :)

View File

@@ -1 +1 @@
4.1.0
4.2.2

View File

@@ -10,7 +10,6 @@ import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/actions/android.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/requirements/android.dart';
import 'package:dartx/dartx.dart';
@@ -72,7 +71,8 @@ class Connection {
}
});
} else if (available == AvailabilityState.poweredOff) {
reset();
disconnectAll();
stop();
}
};
UniversalBle.onScanResult = (result) {
@@ -211,14 +211,6 @@ class Connection {
} else {
isScanning.value = false;
}
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
_androidNotificationsSetup = true;
// start foreground service only when app is in foreground
NotificationRequirement.addPersistentNotification().catchError((e) {
_actionStreams.add(LogNotification(e.toString()));
});
}
}
Future<void> startMyWhooshServer() {
@@ -335,6 +327,14 @@ class Connection {
core.actionHandler.supportedApp?.keymap.addNewButtons(device.availableButtons);
_streamSubscriptions[device] = actionSubscription;
if (devices.isNotEmpty && !_androidNotificationsSetup && !kIsWeb && Platform.isAndroid) {
_androidNotificationsSetup = true;
// start foreground service only when app is in foreground
NotificationRequirement.addPersistentNotification().catchError((e) {
_actionStreams.add(LogNotification(e.toString()));
});
}
} catch (e, backtrace) {
_actionStreams.add(LogNotification("$e\n$backtrace"));
if (kDebugMode) {
@@ -345,31 +345,6 @@ class Connection {
}
}
Future<void> reset() async {
_actionStreams.add(LogNotification('Disconnecting all devices'));
if (core.actionHandler is AndroidActions) {
AndroidFlutterLocalNotificationsPlugin().stopForegroundService();
_androidNotificationsSetup = false;
}
final isBtEnabled = (await UniversalBle.getBluetoothAvailabilityState()) == AvailabilityState.poweredOn;
if (isBtEnabled) {
UniversalBle.stopScan();
}
isScanning.value = false;
for (var device in bluetoothDevices) {
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
UniversalBle.disconnect(device.device.deviceId);
signalChange(device);
}
_gamePadSearchTimer?.cancel();
_lastScanResult.clear();
hasDevices.value = false;
devices.clear();
}
void signalNotification(BaseNotification notification) {
_actionStreams.add(notification);
}
@@ -417,4 +392,29 @@ class Connection {
signalChange(device);
}
Future<void> disconnectAll() async {
_actionStreams.add(LogNotification('Disconnecting all devices'));
for (var device in bluetoothDevices) {
_streamSubscriptions[device]?.cancel();
_streamSubscriptions.remove(device);
_connectionSubscriptions[device]?.cancel();
_connectionSubscriptions.remove(device);
device.disconnect();
signalChange(device);
devices.remove(device);
}
_gamePadSearchTimer?.cancel();
_lastScanResult.clear();
hasDevices.value = false;
}
Future<void> stop() async {
final isBtEnabled = (await UniversalBle.getBluetoothAvailabilityState()) == AvailabilityState.poweredOn;
if (isBtEnabled) {
UniversalBle.stopScan();
}
isScanning.value = false;
_androidNotificationsSetup = false;
}
}

View File

@@ -0,0 +1,174 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
/// Base class for Bluetooth Low Energy (BLE) peripheral emulators.
/// Provides common functionality for peripheral management, service advertising,
/// and connection state handling.
abstract class BluetoothEmulator extends TrainerConnection {
final _peripheralManager = PeripheralManager();
bool _isLoading = false;
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
Central? _central;
BluetoothEmulator({
required super.title,
required super.supportedActions,
});
/// Gets the peripheral manager instance.
@protected
PeripheralManager get peripheralManager => _peripheralManager;
/// Gets whether the emulator is currently loading/initializing.
bool get isLoading => _isLoading;
/// Sets the loading state.
@protected
set isLoading(bool value) => _isLoading = value;
/// Gets whether services have been added to the peripheral manager.
@protected
bool get isServiceAdded => _isServiceAdded;
/// Sets whether services have been added.
@protected
set isServiceAdded(bool value) => _isServiceAdded = value;
/// Gets whether event subscriptions are active.
@protected
bool get isSubscribedToEvents => _isSubscribedToEvents;
/// Sets whether event subscriptions are active.
@protected
set isSubscribedToEvents(bool value) => _isSubscribedToEvents = value;
/// Gets the current connected central device.
@protected
Central? get central => _central;
/// Sets the connected central device.
@protected
set central(Central? value) => _central = value;
/// Subscribes to peripheral manager state changes.
@protected
void subscribeToStateChanges() {
_peripheralManager.stateChanged.forEach((state) {
if (kDebugMode) {
print('Peripheral manager state: ${state.state}');
}
});
}
/// Subscribes to connection state changes on Android.
/// Handles connection and disconnection events.
@protected
void subscribeToConnectionStateChanges(VoidCallback? onUpdate) {
if (!kIsWeb && Platform.isAndroid) {
_peripheralManager.connectionStateChanged.forEach((state) {
if (kDebugMode) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
}
if (state.state == ConnectionState.connected) {
// Override in subclass if needed
} else if (state.state == ConnectionState.disconnected) {
handleDisconnection();
onUpdate?.call();
}
});
}
}
/// Handles disconnection events. Can be overridden by subclasses.
@protected
void handleDisconnection() {
_central = null;
isConnected.value = false;
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
);
}
/// Requests Bluetooth advertise permission on Android.
/// Returns true if permission is granted, false otherwise.
@protected
Future<bool> requestBluetoothAdvertisePermission() async {
if (!kIsWeb && Platform.isAndroid) {
final status = await Permission.bluetoothAdvertise.request();
if (!status.isGranted) {
if (kDebugMode) {
print('Bluetooth advertise permission not granted');
}
return false;
}
}
return true;
}
/// Waits for the peripheral manager to be powered on.
/// Returns true if powered on, false if cancelled (e.g., by user action).
@protected
Future<bool> waitForPoweredOn(bool Function() shouldContinue) async {
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && shouldContinue()) {
if (kDebugMode) {
print('Waiting for peripheral manager to be powered on...');
}
await Future.delayed(const Duration(seconds: 1));
}
return _peripheralManager.state == BluetoothLowEnergyState.poweredOn;
}
/// Adds a GATT service to the peripheral manager.
@protected
Future<void> addService(GATTService service) async {
await _peripheralManager.addService(service);
}
/// Starts advertising with the given advertisement configuration.
@protected
Future<void> startAdvertising(Advertisement advertisement) async {
await _peripheralManager.startAdvertising(advertisement);
}
/// Stops advertising and resets state.
@protected
Future<void> stopAdvertising() async {
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;
_isLoading = false;
}
/// Notifies a characteristic with the given value to the connected central.
@protected
Future<void> notifyCharacteristic(
Central central,
GATTCharacteristic characteristic, {
required Uint8List value,
}) async {
await _peripheralManager.notifyCharacteristic(central, characteristic, value: value);
}
/// Cleans up resources by stopping advertising and removing services.
@protected
void cleanup() {
_peripheralManager.stopAdvertising();
_peripheralManager.removeAllServices();
_isServiceAdded = false;
_isSubscribedToEvents = false;
_central = null;
isConnected.value = false;
isStarted.value = false;
_isLoading = false;
}
}

View File

@@ -0,0 +1,153 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:flutter/foundation.dart';
import 'package:nsd/nsd.dart';
/// Base class for mDNS-based emulators that advertise services over the network.
/// Provides common functionality for TCP server management, mDNS registration,
/// and network interface discovery.
abstract class MdnsEmulator extends TrainerConnection {
ServerSocket? _tcpServer;
Registration? _mdnsRegistration;
Socket? _socket;
MdnsEmulator({
required super.title,
required super.supportedActions,
});
/// Gets the TCP server instance, if available.
@protected
ServerSocket? get tcpServer => _tcpServer;
/// Gets the current client socket connection, if available.
@protected
Socket? get socket => _socket;
/// Sets the client socket connection.
@protected
set socket(Socket? value) => _socket = value;
/// Gets the mDNS registration, if available.
@protected
Registration? get mdnsRegistration => _mdnsRegistration;
/// Finds and returns a local IPv4 address from available network interfaces.
/// Returns null if no suitable address is found.
@protected
Future<InternetAddress?> findLocalIP() async {
final interfaces = await NetworkInterface.list();
InternetAddress? localIP;
for (final interface in interfaces) {
for (final addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
localIP = addr;
break;
}
}
if (localIP != null) break;
}
return localIP;
}
/// Creates a TCP server on the specified port.
/// The server listens on all IPv6 addresses (with v6Only: false to also accept IPv4).
@protected
Future<ServerSocket> createTcpServer(int port) async {
try {
_tcpServer = await ServerSocket.bind(
InternetAddress.anyIPv6,
port,
shared: true,
v6Only: false,
);
if (kDebugMode) {
print('TCP Server started on port ${_tcpServer!.port}');
}
return _tcpServer!;
} catch (e) {
if (kDebugMode) {
print('Failed to start server: $e');
}
rethrow;
}
}
/// Registers an mDNS service with the given configuration.
@protected
Future<Registration> registerMdnsService(Service service) async {
if (kDebugMode) {
enableLogging(LogTopic.calls);
enableLogging(LogTopic.errors);
}
disableServiceTypeValidation(true);
_mdnsRegistration = await register(service);
if (kDebugMode) {
print('mDNS service registered: ${service.name}');
}
return _mdnsRegistration!;
}
/// Unregisters the mDNS service if one is registered.
@protected
void unregisterMdnsService() {
if (_mdnsRegistration != null) {
unregister(_mdnsRegistration!);
_mdnsRegistration = null;
}
}
/// Closes the TCP server and client socket.
@protected
void closeTcpServer() {
_socket?.destroy();
_socket = null;
_tcpServer?.close();
_tcpServer = null;
}
/// Writes data to the client socket.
@protected
void writeToSocket(Socket socket, List<int> data) {
if (kDebugMode) {
print('Sending response: ${bytesToHex(data)}');
}
socket.add(data);
}
/// Stops the emulator by closing connections and unregistering services.
void stop() {
isStarted.value = false;
isConnected.value = false;
closeTcpServer();
unregisterMdnsService();
if (kDebugMode) {
print('Stopped ${runtimeType}');
}
}
}
/// Converts a list of bytes to a hexadecimal string representation.
String bytesToHex(List<int> bytes, {bool spaced = false}) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(spaced ? ' ' : '');
}
/// Converts a list of bytes to a readable hexadecimal string with spaces.
String bytesToReadableHex(List<int> bytes) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(' ');
}
/// Converts a hexadecimal string to a list of bytes.
List<int> hexToBytes(String hex) {
final bytes = <int>[];
for (var i = 0; i < hex.length; i += 2) {
final byte = hex.substring(i, i + 2);
bytes.add(int.parse(byte, radix: 16));
}
return bytes;
}

View File

@@ -1,8 +1,8 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/bluetooth_emulator.dart';
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
@@ -15,12 +15,8 @@ import 'package:flutter/foundation.dart';
import '../../messages/notification.dart' show AlertNotification;
class OpenBikeControlBluetoothEmulator extends TrainerConnection {
late final _peripheralManager = PeripheralManager();
class OpenBikeControlBluetoothEmulator extends BluetoothEmulator {
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier<AppInfo?>(null);
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
Central? _central;
late GATTCharacteristic _buttonCharacteristic;
@@ -35,13 +31,13 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
Future<void> startServer() async {
isStarted.value = true;
_peripheralManager.stateChanged.forEach((state) {
print('Peripheral manager state: ${state.state}');
});
subscribeToStateChanges();
if (!kIsWeb && Platform.isAndroid) {
_peripheralManager.connectionStateChanged.forEach((state) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
peripheralManager.connectionStateChanged.forEach((state) {
if (kDebugMode) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
}
if (state.state == ConnectionState.connected) {
} else if (state.state == ConnectionState.disconnected) {
if (connectedApp.value != null) {
@@ -51,14 +47,13 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
}
isConnected.value = false;
connectedApp.value = null;
_central = null;
central = null;
}
});
}
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getObpBleEnabled()) {
print('Waiting for peripheral manager to be powered on...');
await Future.delayed(Duration(seconds: 1));
if (!await waitForPoweredOn(() => core.settings.getObpBleEnabled())) {
return;
}
_buttonCharacteristic = GATTCharacteristic.mutable(
@@ -70,12 +65,12 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
permissions: [],
);
if (!_isServiceAdded) {
await Future.delayed(Duration(seconds: 1));
if (!isServiceAdded) {
await Future.delayed(const Duration(seconds: 1));
if (!_isSubscribedToEvents) {
_isSubscribedToEvents = true;
_peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
if (!isSubscribedToEvents) {
isSubscribedToEvents = true;
peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
@@ -85,20 +80,20 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
final request = eventArgs.request;
final trimmedValue = Uint8List.fromList([]);
await _peripheralManager.respondReadRequestWithValue(
await peripheralManager.respondReadRequestWithValue(
request,
value: trimmedValue,
);
// You can respond to read requests here if needed
});
_peripheralManager.characteristicNotifyStateChanged.forEach((char) {
_central = char.central;
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
central = char.central;
print(
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
);
});
_peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
final characteristic = eventArgs.characteristic;
final request = eventArgs.request;
final value = request.value;
@@ -125,13 +120,13 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
print('Unhandled write request for characteristic: ${eventArgs.characteristic.uuid}');
}
await _peripheralManager.respondWriteRequest(request);
await peripheralManager.respondWriteRequest(request);
});
}
if (!Platform.isWindows) {
// Device Information
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString('180A'),
isPrimary: true,
@@ -162,7 +157,7 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
);
}
// Battery Service
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString('180F'),
isPrimary: true,
@@ -184,7 +179,7 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
);
// Unknown Service
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString(OpenBikeControlConstants.SERVICE_UUID),
isPrimary: true,
@@ -206,7 +201,7 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
includedServices: [],
),
);
_isServiceAdded = true;
isServiceAdded = true;
}
final advertisement = Advertisement(
@@ -215,16 +210,14 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
);
print('Starting advertising with OpenBikeControl service...');
await _peripheralManager.startAdvertising(advertisement);
await startAdvertising(advertisement);
}
Future<void> stopServer() async {
if (kDebugMode) {
print('Stopping OpenBikeControl BLE server...');
}
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;
await stopAdvertising();
connectedApp.value = null;
}
@@ -238,7 +231,7 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
if (inGameAction == null) {
return Error('Invalid in-game action for key pair: $keyPair');
} else if (_central == null) {
} else if (central == null) {
return Error('No central connected');
} else if (connectedApp.value == null) {
return Error('No app info received from central');
@@ -250,16 +243,16 @@ class OpenBikeControlBluetoothEmulator extends TrainerConnection {
final responseDataDown = OpenBikeProtocolParser.encodeButtonState(
mappedButtons.map((b) => ButtonState(b, 1)).toList(),
);
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataDown);
await notifyCharacteristic(central!, _buttonCharacteristic, value: responseDataDown);
final responseDataUp = OpenBikeProtocolParser.encodeButtonState(
mappedButtons.map((b) => ButtonState(b, 0)).toList(),
);
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseDataUp);
await notifyCharacteristic(central!, _buttonCharacteristic, value: responseDataUp);
} else {
final responseData = OpenBikeProtocolParser.encodeButtonState(
mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(),
);
await _peripheralManager.notifyCharacteristic(_central!, _buttonCharacteristic, value: responseData);
await notifyCharacteristic(central!, _buttonCharacteristic, value: responseData);
}
return Success('Buttons ${inGameAction.title} sent');

View File

@@ -1,10 +1,8 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/mdns_emulator.dart';
import 'package:bike_control/bluetooth/devices/openbikecontrol/openbikecontrol_device.dart';
import 'package:bike_control/bluetooth/devices/openbikecontrol/protocol_parser.dart';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
@@ -14,16 +12,11 @@ import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:nsd/nsd.dart';
class OpenBikeControlMdnsEmulator extends TrainerConnection {
ServerSocket? _server;
Registration? _mdnsRegistration;
class OpenBikeControlMdnsEmulator extends MdnsEmulator {
static const String connectionTitle = 'OpenBikeControl mDNS Emulator';
final ValueNotifier<AppInfo?> connectedApp = ValueNotifier(null);
Socket? _socket;
OpenBikeControlMdnsEmulator()
: super(
title: connectionTitle,
@@ -35,18 +28,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
isStarted.value = true;
// Get local IP
final interfaces = await NetworkInterface.list();
InternetAddress? localIP;
for (final interface in interfaces) {
for (final addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
localIP = addr;
break;
}
}
if (localIP != null) break;
}
final localIP = await findLocalIP();
if (localIP == null) {
throw 'Could not find network interface';
@@ -54,15 +36,9 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
await _createTcpServer();
if (kDebugMode) {
enableLogging(LogTopic.calls);
enableLogging(LogTopic.errors);
}
disableServiceTypeValidation(true);
try {
// Create service
_mdnsRegistration = await register(
await registerMdnsService(
Service(
name: 'BikeControl',
type: '_openbikecontrol._tcp',
@@ -79,7 +55,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
},
),
);
print('Service: ${_mdnsRegistration!.id} at ${localIP.address}:$_mdnsRegistration');
print('Service: ${mdnsRegistration!.id} at ${localIP.address}:$mdnsRegistration');
print('Server started - advertising service!');
} catch (e, s) {
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start mDNS server: $e'));
@@ -91,37 +67,17 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
if (kDebugMode) {
print('Stopping OpenBikeControl mDNS server...');
}
if (_mdnsRegistration != null) {
unregister(_mdnsRegistration!);
_mdnsRegistration = null;
}
isStarted.value = false;
isConnected.value = false;
stop();
connectedApp.value = null;
_socket?.destroy();
_socket = null;
}
Future<void> _createTcpServer() async {
try {
_server = await ServerSocket.bind(
InternetAddress.anyIPv6,
36867,
shared: true,
v6Only: false,
);
} catch (e) {
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Failed to start server: $e'));
rethrow;
}
if (kDebugMode) {
print('Server started on port ${_server!.port}');
}
await createTcpServer(36867);
// Accept connection
_server!.listen(
tcpServer!.listen(
(Socket socket) {
_socket = socket;
this.socket = socket;
if (kDebugMode) {
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
@@ -155,7 +111,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
);
isConnected.value = false;
connectedApp.value = null;
_socket = null;
this.socket = null;
},
);
},
@@ -172,7 +128,7 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
if (inGameAction == null) {
return Error('Invalid in-game action for key pair: $keyPair');
} else if (_socket == null) {
} else if (socket == null) {
print('No client connected, cannot send button press');
return Error('No client connected');
} else if (connectedApp.value == null) {
@@ -185,23 +141,18 @@ class OpenBikeControlMdnsEmulator extends TrainerConnection {
final responseDataDown = OpenBikeProtocolParser.encodeButtonState(
mappedButtons.map((b) => ButtonState(b, 1)).toList(),
);
_write(_socket!, responseDataDown);
writeToSocket(socket!, responseDataDown);
final responseDataUp = OpenBikeProtocolParser.encodeButtonState(
mappedButtons.map((b) => ButtonState(b, 0)).toList(),
);
_write(_socket!, responseDataUp);
writeToSocket(socket!, responseDataUp);
} else {
final responseData = OpenBikeProtocolParser.encodeButtonState(
mappedButtons.map((b) => ButtonState(b, isKeyDown ? 1 : 0)).toList(),
);
_write(_socket!, responseData);
writeToSocket(socket!, responseData);
}
return Success('Sent ${inGameAction.title} button press');
}
void _write(Socket socket, List<int> responseData) {
debugPrint('Sending response: ${bytesToHex(responseData)}');
socket.add(responseData);
}
}

View File

@@ -114,6 +114,7 @@ class SramAxs extends BluetoothDevice {
children: [
super.showInformation(context),
Text(
"Don't forget to turn off the function of the button you want to use in the SRAM AXS app!\n"
"Unfortunately, at the moment it's not possible to determine which physical button was pressed on your SRAM AXS device. Let us know if you have a contact at SRAM who can help :)\n\n"
'So the app exposes two logical buttons:\n'
'• SRAM Tap, assigned to Shift Up\n'

View File

@@ -1,6 +1,6 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/devices/mdns_emulator.dart';
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pbenum.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zwift.pb.dart' show RideKeyPadStatus;
@@ -15,13 +15,9 @@ import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:nsd/nsd.dart';
class FtmsMdnsEmulator extends TrainerConnection {
ServerSocket? _tcpServer;
Registration? _mdnsRegistration;
class FtmsMdnsEmulator extends MdnsEmulator {
static const String connectionTitle = 'Zwift Network Emulator';
Socket? _socket;
var lastMessageId = 0;
FtmsMdnsEmulator()
@@ -46,18 +42,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
print('Starting mDNS server...');
// Get local IP
final interfaces = await NetworkInterface.list();
InternetAddress? localIP;
for (final interface in interfaces) {
for (final addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
localIP = addr;
break;
}
}
if (localIP != null) break;
}
final localIP = await findLocalIP();
if (localIP == null) {
throw 'Could not find network interface';
@@ -65,13 +50,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
await _createTcpServer();
if (kDebugMode) {
enableLogging(LogTopic.calls);
enableLogging(LogTopic.errors);
}
disableServiceTypeValidation(true);
_mdnsRegistration = await register(
await registerMdnsService(
Service(
name: 'KICKR BIKE PRO 1337',
addresses: [localIP],
@@ -87,41 +66,13 @@ class FtmsMdnsEmulator extends TrainerConnection {
print('Server started - advertising service!');
}
void stop() {
isStarted.value = false;
isConnected.value = false;
_tcpServer?.close();
if (_mdnsRegistration != null) {
unregister(_mdnsRegistration!);
}
_tcpServer = null;
_mdnsRegistration = null;
_socket = null;
print('Stopped FtmsMdnsEmulator');
}
Future<void> _createTcpServer() async {
try {
_tcpServer = await ServerSocket.bind(
InternetAddress.anyIPv6,
36867,
shared: true,
v6Only: false,
);
} catch (e) {
if (kDebugMode) {
print('Failed to start server: $e');
}
rethrow;
}
if (kDebugMode) {
print('Server started on port ${_tcpServer!.port}');
}
await createTcpServer(36867);
// Accept connection
_tcpServer!.listen(
tcpServer!.listen(
(Socket socket) {
_socket = socket;
this.socket = socket;
isConnected.value = true;
if (kDebugMode) {
print('Client connected: ${socket.remoteAddress.address}:${socket.remotePort}');
@@ -171,7 +122,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
// Expected 0101000000100000fc8200001000800000805f9b34fb
// Got 0101000000100000fc8200001000800000805f9b34fb
_write(socket, bytes);
writeToSocket(socket, bytes);
case FtmsMdnsConstants.DC_MESSAGE_DISCOVER_CHARACTERISTICS:
final rawUUID = body.takeBytes(16);
final serviceUUID = bytesToHex(rawUUID).toUUID();
@@ -201,7 +152,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
];
// OK: 0102010000430000fc8200001000800000805f9b34fb0000000319ca465186e5fa29dcdd09d1020000000219ca465186e5fa29dcdd09d1040000000419ca465186e5fa29dcdd09d104
_write(socket, responseData);
writeToSocket(socket, responseData);
}
case FtmsMdnsConstants.DC_MESSAGE_READ_CHARACTERISTIC:
final rawUUID = body.takeBytes(16);
@@ -220,7 +171,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
...responseBody,
];
_write(socket, responseData);
writeToSocket(socket, responseData);
case FtmsMdnsConstants.DC_MESSAGE_WRITE_CHARACTERISTIC:
final rawUUID = body.takeBytes(16);
final characteristicUUID = bytesToHex(rawUUID).toUUID();
@@ -239,7 +190,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
...responseBody,
];
_write(socket, responseData);
writeToSocket(socket, responseData);
final response = core.zwiftEmulator.handleWriteRequest(
characteristicUUID,
@@ -269,7 +220,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
];
// 0106050000180000000419ca465186e5fa29dcdd09d1526964654f6e0203
_write(socket, responseData);
writeToSocket(socket, responseData);
if (response.contentEquals(ZwiftConstants.RIDE_ON)) {
_sendKeepAlive();
@@ -293,7 +244,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
...responseBody,
];
_write(socket, responseData);
writeToSocket(socket, responseData);
case FtmsMdnsConstants.DC_MESSAGE_CHARACTERISTIC_NOTIFICATION:
print('Hamlo');
default:
@@ -308,20 +259,13 @@ class FtmsMdnsEmulator extends TrainerConnection {
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
);
_socket = null;
this.socket = null;
},
);
},
);
}
void _write(Socket socket, List<int> responseData) {
if (kDebugMode) {
print('Sending response: ${bytesToHex(responseData)}');
}
socket.add(responseData);
}
int _propertyVal(List<String> properties) {
int res = 0;
@@ -368,7 +312,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
]),
);
_write(_socket!, commandProto);
writeToSocket(socket!, commandProto);
}
if (isKeyUp) {
@@ -377,7 +321,7 @@ class FtmsMdnsEmulator extends TrainerConnection {
Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]),
);
_write(_socket!, zero);
writeToSocket(socket!, zero);
}
if (kDebugMode) {
print('Sent action $isKeyUp vs $isKeyDown ${keyPair.inGameAction!.title} to Zwift Emulator');
@@ -411,9 +355,9 @@ class FtmsMdnsEmulator extends TrainerConnection {
Future<void> _sendKeepAlive() async {
await Future.delayed(const Duration(seconds: 5));
if (_socket != null) {
_write(
_socket!,
if (socket != null) {
writeToSocket(
socket!,
_buildNotify(
ZwiftConstants.ZWIFT_SYNC_TX_CHARACTERISTIC_UUID,
hexToBytes('B70100002041201C00180004001B4F00B701000020798EC5BDEFCBE4563418269E4926FBE1'),
@@ -464,23 +408,6 @@ extension on List<int> {
}
}
String bytesToHex(List<int> bytes, {bool spaced = false}) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(spaced ? ' ' : '');
}
String bytesToReadableHex(List<int> bytes) {
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(' ');
}
List<int> hexToBytes(String hex) {
final bytes = <int>[];
for (var i = 0; i < hex.length; i += 2) {
final byte = hex.substring(i, i + 2);
bytes.add(int.parse(byte, radix: 16));
}
return bytes;
}
class FtmsMdnsConstants {
static const DC_RC_REQUEST_COMPLETED_SUCCESSFULLY = 0; // Request completed successfully
static const DC_RC_UNKNOWN_MESSAGE_TYPE = 1; // Unknown Message Type

View File

@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:bike_control/bluetooth/ble.dart';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/devices/bluetooth_emulator.dart';
import 'package:bike_control/bluetooth/devices/zwift/constants.dart';
import 'package:bike_control/bluetooth/devices/zwift/ftms_mdns_emulator.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
@@ -17,18 +17,10 @@ import 'package:bike_control/utils/requirements/multi.dart';
import 'package:bike_control/widgets/title.dart';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
class ZwiftEmulator extends TrainerConnection {
bool get isLoading => _isLoading;
class ZwiftEmulator extends BluetoothEmulator {
static const String connectionTitle = 'Zwift BLE Emulator';
late final _peripheralManager = PeripheralManager();
bool _isLoading = false;
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
Central? _central;
GATTCharacteristic? _asyncCharacteristic;
GATTCharacteristic? _syncTxCharacteristic;
@@ -50,51 +42,30 @@ class ZwiftEmulator extends TrainerConnection {
);
Future<void> reconnect() async {
await _peripheralManager.stopAdvertising();
await _peripheralManager.removeAllServices();
_isServiceAdded = false;
await peripheralManager.stopAdvertising();
await peripheralManager.removeAllServices();
isServiceAdded = false;
startAdvertising(() {});
}
Future<void> startAdvertising(VoidCallback onUpdate) async {
_isLoading = true;
isLoading = true;
isStarted.value = true;
onUpdate();
_peripheralManager.stateChanged.forEach((state) {
print('Peripheral manager state: ${state.state}');
});
subscribeToStateChanges();
subscribeToConnectionStateChanges(onUpdate);
if (!kIsWeb && Platform.isAndroid) {
_peripheralManager.connectionStateChanged.forEach((state) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
if (state.state == ConnectionState.connected) {
} else if (state.state == ConnectionState.disconnected) {
_central = null;
isConnected.value = false;
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
);
onUpdate();
}
});
final status = await Permission.bluetoothAdvertise.request();
if (!status.isGranted) {
print('Bluetooth advertise permission not granted');
isStarted.value = false;
onUpdate();
return;
}
if (!await requestBluetoothAdvertisePermission()) {
isStarted.value = false;
onUpdate();
return;
}
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn &&
core.settings.getZwiftBleEmulatorEnabled()) {
print('Waiting for peripheral manager to be powered on...');
if (!await waitForPoweredOn(() => core.settings.getZwiftBleEmulatorEnabled())) {
if (core.settings.getLastTarget() == Target.thisDevice) {
return;
}
await Future.delayed(Duration(seconds: 1));
}
_syncTxCharacteristic = GATTCharacteristic.mutable(
@@ -118,12 +89,12 @@ class ZwiftEmulator extends TrainerConnection {
permissions: [],
);
if (!_isServiceAdded) {
await Future.delayed(Duration(seconds: 1));
if (!isServiceAdded) {
await Future.delayed(const Duration(seconds: 1));
if (!_isSubscribedToEvents) {
_isSubscribedToEvents = true;
_peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
if (!isSubscribedToEvents) {
isSubscribedToEvents = true;
peripheralManager.characteristicReadRequested.forEach((eventArgs) async {
print('Read request for characteristic: ${eventArgs.characteristic.uuid}');
switch (eventArgs.characteristic.uuid.toString().toUpperCase()) {
@@ -131,7 +102,7 @@ class ZwiftEmulator extends TrainerConnection {
print('Handling read request for SYNC TX characteristic');
break;
case BleUuid.DEVICE_INFORMATION_CHARACTERISTIC_BATTERY_LEVEL:
await _peripheralManager.respondReadRequestWithValue(
await peripheralManager.respondReadRequestWithValue(
eventArgs.request,
value: Uint8List.fromList([100]),
);
@@ -142,20 +113,20 @@ class ZwiftEmulator extends TrainerConnection {
final request = eventArgs.request;
final trimmedValue = Uint8List.fromList([]);
await _peripheralManager.respondReadRequestWithValue(
await peripheralManager.respondReadRequestWithValue(
request,
value: trimmedValue,
);
// You can respond to read requests here if needed
});
_peripheralManager.characteristicNotifyStateChanged.forEach((char) {
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
print(
'Notify state changed for characteristic: ${char.characteristic.uuid}: ${char.state}',
);
});
_peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
_central = eventArgs.central;
peripheralManager.characteristicWriteRequested.forEach((eventArgs) async {
central = eventArgs.central;
isConnected.value = true;
core.connection.signalNotification(
@@ -165,8 +136,8 @@ class ZwiftEmulator extends TrainerConnection {
final request = eventArgs.request;
final response = handleWriteRequest(eventArgs.characteristic.uuid.toString(), request.value);
if (response != null) {
await _peripheralManager.notifyCharacteristic(
_central!,
await notifyCharacteristic(
central!,
_syncTxCharacteristic!,
value: response,
);
@@ -176,13 +147,13 @@ class ZwiftEmulator extends TrainerConnection {
}
}
await _peripheralManager.respondWriteRequest(request);
await peripheralManager.respondWriteRequest(request);
});
}
if (!Platform.isWindows) {
// Device Information
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString('180A'),
isPrimary: true,
@@ -213,7 +184,7 @@ class ZwiftEmulator extends TrainerConnection {
);
}
// Battery Service
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString('180F'),
isPrimary: true,
@@ -235,7 +206,7 @@ class ZwiftEmulator extends TrainerConnection {
);
// Unknown Service
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString(ZwiftConstants.ZWIFT_CUSTOM_SERVICE_UUID),
isPrimary: true,
@@ -276,7 +247,7 @@ class ZwiftEmulator extends TrainerConnection {
includedServices: [],
),
);
_isServiceAdded = true;
isServiceAdded = true;
}
final advertisement = Advertisement(
@@ -294,23 +265,21 @@ class ZwiftEmulator extends TrainerConnection {
);
print('Starting advertising with Zwift service...');
await _peripheralManager.startAdvertising(advertisement);
_isLoading = false;
await startAdvertising(advertisement);
isLoading = false;
onUpdate();
}
@override
Future<void> stopAdvertising() async {
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;
_isLoading = false;
await super.stopAdvertising();
}
Future<void> _sendKeepAlive() async {
await Future.delayed(const Duration(seconds: 5));
if (isConnected.value && _central != null) {
if (isConnected.value && central != null) {
final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
_peripheralManager.notifyCharacteristic(_central!, _syncTxCharacteristic!, value: zero);
await notifyCharacteristic(central!, _syncTxCharacteristic!, value: zero);
_sendKeepAlive();
}
}
@@ -347,8 +316,8 @@ class ZwiftEmulator extends TrainerConnection {
...bytes,
]);
_peripheralManager.notifyCharacteristic(
_central!,
await notifyCharacteristic(
central!,
_asyncCharacteristic!,
value: commandProto,
);
@@ -356,7 +325,7 @@ class ZwiftEmulator extends TrainerConnection {
if (isKeyUp) {
final zero = Uint8List.fromList([Opcode.CONTROLLER_NOTIFICATION.value, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
_peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero);
await notifyCharacteristic(central!, _asyncCharacteristic!, value: zero);
}
return Success('Sent action: ${keyPair.inGameAction!.name}');
@@ -462,14 +431,8 @@ class ZwiftEmulator extends TrainerConnection {
return null;
}
@override
void cleanup() {
_peripheralManager.stopAdvertising();
_peripheralManager.removeAllServices();
_isServiceAdded = false;
_isSubscribedToEvents = false;
_central = null;
isConnected.value = false;
isStarted.value = false;
_isLoading = false;
super.cleanup();
}
}

View File

@@ -1,6 +1,6 @@
import 'dart:io';
import 'package:bike_control/bluetooth/devices/trainer_connection.dart';
import 'package:bike_control/bluetooth/devices/bluetooth_emulator.dart';
import 'package:bike_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
@@ -11,19 +11,10 @@ import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/utils/requirements/multi.dart';
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
import 'package:flutter/foundation.dart';
import 'package:permission_handler/permission_handler.dart';
import '../utils/keymap/keymap.dart';
class RemotePairing extends TrainerConnection {
bool get isLoading => _isLoading;
late final _peripheralManager = PeripheralManager();
bool _isLoading = false;
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
Central? _central;
class RemotePairing extends BluetoothEmulator {
GATTCharacteristic? _inputReport;
static const String connectionTitle = 'Remote Control';
@@ -35,9 +26,9 @@ class RemotePairing extends TrainerConnection {
);
Future<void> reconnect() async {
await _peripheralManager.stopAdvertising();
await _peripheralManager.removeAllServices();
_isServiceAdded = false;
await peripheralManager.stopAdvertising();
await peripheralManager.removeAllServices();
isServiceAdded = false;
startAdvertising().catchError((e) {
core.settings.setRemoteControlEnabled(false);
core.connection.signalNotification(
@@ -47,40 +38,21 @@ class RemotePairing extends TrainerConnection {
}
Future<void> startAdvertising() async {
_isLoading = true;
isLoading = true;
isStarted.value = true;
_peripheralManager.stateChanged.forEach((state) {
print('Peripheral manager state: ${state.state}');
});
subscribeToStateChanges();
subscribeToConnectionStateChanges(null);
if (!kIsWeb && Platform.isAndroid) {
_peripheralManager.connectionStateChanged.forEach((state) {
print('Peripheral connection state: ${state.state} of ${state.central.uuid}');
if (state.state == ConnectionState.connected) {
} else if (state.state == ConnectionState.disconnected) {
_central = null;
isConnected.value = false;
core.connection.signalNotification(
AlertNotification(LogLevel.LOGLEVEL_INFO, AppLocalizations.current.disconnected),
);
}
});
final status = await Permission.bluetoothAdvertise.request();
if (!status.isGranted) {
print('Bluetooth advertise permission not granted');
isStarted.value = false;
return;
}
if (!await requestBluetoothAdvertisePermission()) {
isStarted.value = false;
return;
}
while (_peripheralManager.state != BluetoothLowEnergyState.poweredOn && core.settings.getRemoteControlEnabled()) {
print('Waiting for peripheral manager to be powered on...');
if (!await waitForPoweredOn(() => core.settings.getRemoteControlEnabled())) {
if (core.settings.getLastTarget() == Target.thisDevice) {
return;
}
await Future.delayed(Duration(seconds: 1));
}
final inputReport = GATTCharacteristic.mutable(
uuid: UUID.fromString('2A4D'),
@@ -95,8 +67,8 @@ class RemotePairing extends TrainerConnection {
],
);
if (!_isServiceAdded) {
await Future.delayed(Duration(seconds: 1));
if (!isServiceAdded) {
await Future.delayed(const Duration(seconds: 1));
final reportMapDataAbsolute = Uint8List.fromList([
0x05, 0x01, // Usage Page (Generic Desktop)
@@ -206,21 +178,21 @@ class RemotePairing extends TrainerConnection {
includedServices: [],
);
if (!_isSubscribedToEvents) {
_isSubscribedToEvents = true;
_peripheralManager.characteristicReadRequested.forEach((char) {
if (!isSubscribedToEvents) {
isSubscribedToEvents = true;
peripheralManager.characteristicReadRequested.forEach((char) {
print('Read request for characteristic: ${char}');
// You can respond to read requests here if needed
});
_peripheralManager.characteristicNotifyStateChanged.forEach((char) {
peripheralManager.characteristicNotifyStateChanged.forEach((char) {
if (char.characteristic.uuid == inputReport.uuid) {
if (char.state) {
_inputReport = char.characteristic;
_central = char.central;
central = char.central;
} else {
_inputReport = null;
_central = null;
central = null;
}
}
print(
@@ -228,10 +200,10 @@ class RemotePairing extends TrainerConnection {
);
});
}
await _peripheralManager.addService(hidService);
await addService(hidService);
// 3) Optional Battery service
await _peripheralManager.addService(
await addService(
GATTService(
uuid: UUID.fromString('180F'),
isPrimary: true,
@@ -245,7 +217,7 @@ class RemotePairing extends TrainerConnection {
includedServices: [],
),
);
_isServiceAdded = true;
isServiceAdded = true;
}
final advertisement = Advertisement(
@@ -259,20 +231,18 @@ class RemotePairing extends TrainerConnection {
);
print('Starting advertising with Zwift service...');
await _peripheralManager.startAdvertising(advertisement);
_isLoading = false;
await startAdvertising(advertisement);
isLoading = false;
}
@override
Future<void> stopAdvertising() async {
await _peripheralManager.stopAdvertising();
isStarted.value = false;
isConnected.value = false;
_isLoading = false;
await super.stopAdvertising();
}
Future<void> notifyCharacteristic(Uint8List value) async {
if (_inputReport != null && _central != null) {
await _peripheralManager.notifyCharacteristic(_central!, _inputReport!, value: value);
if (_inputReport != null && central != null) {
await peripheralManager.notifyCharacteristic(central!, _inputReport!, value: value);
}
}

View File

@@ -20,6 +20,8 @@
"allowLocationForBluetooth": "Standortzugriff erlauben, damit Bluetooth-Scan funktioniert",
"allowPersistentNotification": "Benachrichtigungen zulassen",
"allowsRunningInBackground": "Ermöglicht es BikeControl, im Hintergrund weiterzulaufen.",
"alreadyBoughtTheApp": "App bereits gekauft? Dann musst Du BikeControl nicht erneut kaufen. Aus technischen Gründen lässt sich leider nicht feststellen, ob die App früher bereits erworben wurde. \n\nGebe Deine Play Store-Kauf-ID (z.B. GPA.3356-1337-1338-1339) ein, um die Vollversion freizuschalten. Falls Du diese nicht finden kannst, kontaktiere mich bitte direkt.",
"alreadyBoughtTheAppPreviously": "App zuvor bereits gekauft?",
"appIdActions": "{appId} Aktionen",
"@appIdActions": {
"placeholders": {
@@ -252,6 +254,7 @@
}
},
"next": "Nächste",
"no": "Nein",
"noActionAssigned": "Keine Maßnahmen zugewiesen",
"noActionAssignedForButton": "{button} konnte nicht ausgeführt werden: Keine Aktion zugewiesen",
"noConnectionMethodIsConnectedOrActive": "Es ist keine Verbindungsmethode verbunden oder aktiv.",
@@ -422,6 +425,7 @@
},
"whatsNew": "Was ist neu",
"whyPermissionNeeded": "Wozu wird diese Berechtigung benötigt?",
"yes": "Ja",
"zwiftControllerAction": "Zwift Controller-Aktion",
"zwiftControllerDescription": "Ermöglicht es BikeControl, als Zwift-kompatibler Controller zu fungieren."
}

View File

@@ -20,6 +20,8 @@
"allowLocationForBluetooth": "Allow Location so Bluetooth scan works",
"allowPersistentNotification": "Allow Notifications",
"allowsRunningInBackground": "Allows BikeControl to keep running in background",
"alreadyBoughtTheApp": "Already bought the app? You don't need to pay for BikeControl, again. Due to technical difficulties, it's not possible to determine if the app was bought already.\n\nEnter your Play Store purchase ID (e.g. GPA.3356-1337-1338-1339) to unlock the full version. If you can't find it, please get in touch with me directly.",
"alreadyBoughtTheAppPreviously": "Already bought the app previously?",
"appIdActions": "{appId} actions",
"@appIdActions": {
"placeholders": {
@@ -252,6 +254,7 @@
}
},
"next": "Next",
"no": "No",
"noActionAssigned": "No action assigned",
"noActionAssignedForButton": "Could not perform {button}: No action assigned",
"noConnectionMethodIsConnectedOrActive": "No connection method is connected or active.",
@@ -422,6 +425,7 @@
},
"whatsNew": "What's New",
"whyPermissionNeeded": "Why is this permission needed?",
"yes": "Yes",
"zwiftControllerAction": "Zwift Controller Action",
"zwiftControllerDescription": "Enables BikeControl to act as a Zwift-compatible controller."
}

View File

@@ -20,6 +20,8 @@
"allowLocationForBluetooth": "Autoriser la localisation pour que la recherche Bluetooth fonctionne",
"allowPersistentNotification": "Autoriser les notifications",
"allowsRunningInBackground": "Permet à BikeControl de continuer à fonctionner en arrière-plan",
"alreadyBoughtTheApp": "Vous avez déjà acheté l'application ? Vous n'avez pas besoin de payer BikeControl une seconde fois. En raison de difficultés techniques, il est impossible de déterminer si l'application a déjà été achetée. \n\nSaisissez votre identifiant d'achat Play Store (par exemple : GPA.3356-1337-1338-1339) pour débloquer la version complète. Si vous ne le trouvez pas, veuillez me contacter directement.",
"alreadyBoughtTheAppPreviously": "Vous avez déjà acheté l'application ?",
"appIdActions": "{appId} actions",
"@appIdActions": {
"placeholders": {
@@ -252,6 +254,7 @@
}
},
"next": "Suivant",
"no": "Non",
"noActionAssigned": "Aucune action assignée",
"noActionAssignedForButton": "Impossible d'effectuer {button}: Aucune action assignée",
"noConnectionMethodIsConnectedOrActive": "Aucune méthode de connexion n'est établie ou active.",
@@ -422,6 +425,7 @@
},
"whatsNew": "Quoi de neuf",
"whyPermissionNeeded": "Pourquoi cette autorisation est-elle nécessaire ?",
"yes": "Oui",
"zwiftControllerAction": "Action du contrôleur Zwift",
"zwiftControllerDescription": "Permet à BikeControl de fonctionner comme une manette compatible avec Zwift."
}

View File

@@ -20,6 +20,8 @@
"allowLocationForBluetooth": "Zezwól na dostęp do lokalizacji, aby umożliwić skanowanie Bluetooth",
"allowPersistentNotification": "Zezwól na powiadomienia",
"allowsRunningInBackground": "Umożliwia działanie BikeControl w tle",
"alreadyBoughtTheApp": "Kupiłeś już aplikację? Nie musisz płacić za BikeControl. Z powodu problemów technicznych nie można ustalić, czy aplikacja została już zakupiona. \n\nWprowadź swój identyfikator zakupu w Sklepie Play (np. GPA.3356-1337-1338-1339), aby odblokować pełną wersję. Jeśli nie możesz jej znaleźć, skontaktuj się ze mną bezpośrednio.",
"alreadyBoughtTheAppPreviously": "Już wcześniej kupiłeś aplikację?",
"appIdActions": "{appId} działania",
"@appIdActions": {
"placeholders": {
@@ -252,6 +254,7 @@
}
},
"next": "Dalej",
"no": "Nie",
"noActionAssigned": "Brak przypisanej akcji",
"noActionAssignedForButton": "Nie można wykonać {button}: Brak przypisanej akcji",
"noConnectionMethodIsConnectedOrActive": "Żadna metoda połączenia nie jest podłączona lub aktywna.",
@@ -422,6 +425,7 @@
},
"whatsNew": "Co nowego",
"whyPermissionNeeded": "Dlaczego to uprawnienie jest potrzebne?",
"yes": "Tak",
"zwiftControllerAction": "Akcja kontrolera Zwift",
"zwiftControllerDescription": "Umożliwia BikeControl działanie jako kontroler kompatybilny ze Zwift."
}

View File

@@ -11,8 +11,6 @@ import 'package:bike_control/widgets/menu.dart';
import 'package:bike_control/widgets/testbed.dart';
import 'package:bike_control/widgets/ui/colors.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_localizations/flutter_localizations.dart'
show GlobalMaterialLocalizations, GlobalWidgetsLocalizations;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'pages/navigation.dart';
@@ -180,9 +178,8 @@ class BikeControlApp extends StatelessWidget {
menuHandler: PopoverOverlayHandler(),
popoverHandler: PopoverOverlayHandler(),
localizationsDelegates: [
...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate,
ShadcnLocalizations.delegate,
...ShadcnLocalizations.localizationsDelegates,
OtherLocalizationsDelegate(),
AppLocalizations.delegate,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
@@ -220,3 +217,19 @@ class BikeControlApp extends StatelessWidget {
);
}
}
class OtherLocalizationsDelegate extends LocalizationsDelegate<ShadcnLocalizations> {
const OtherLocalizationsDelegate();
@override
bool isSupported(Locale locale) =>
AppLocalizations.delegate.supportedLocales.map((e) => e.languageCode).contains(locale.languageCode);
@override
Future<ShadcnLocalizations> load(Locale locale) async {
return SynchronousFuture<ShadcnLocalizations>(lookupShadcnLocalizations(Locale('en')));
}
@override
bool shouldReload(covariant LocalizationsDelegate<ShadcnLocalizations> old) => false;
}

View File

@@ -51,6 +51,13 @@ class _NavigationState extends State<Navigation> {
bool _isMobile = false;
late BCPage _selectedPage;
final Map<BCPage, Key> _pageKeys = {
BCPage.devices: Key('devices_page'),
BCPage.trainer: Key('trainer_page'),
BCPage.customization: Key('customization_page'),
BCPage.logs: Key('logs_page'),
};
@override
void initState() {
super.initState();
@@ -162,6 +169,7 @@ class _NavigationState extends State<Navigation> {
duration: Duration(milliseconds: 200),
child: switch (_selectedPage) {
BCPage.devices => DevicePage(
key: _pageKeys[BCPage.devices],
onUpdate: () {
setState(() {
_selectedPage = BCPage.trainer;
@@ -169,6 +177,7 @@ class _NavigationState extends State<Navigation> {
},
),
BCPage.trainer => TrainerPage(
key: _pageKeys[BCPage.trainer],
onUpdate: () {
setState(() {});
},
@@ -178,8 +187,12 @@ class _NavigationState extends State<Navigation> {
});
},
),
BCPage.customization => CustomizePage(),
BCPage.logs => LogViewer(),
BCPage.customization => CustomizePage(
key: _pageKeys[BCPage.customization],
),
BCPage.logs => LogViewer(
key: _pageKeys[BCPage.logs],
),
},
),
),

View File

@@ -4,7 +4,6 @@ import 'package:bike_control/utils/actions/base_actions.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:bike_control/utils/keymap/buttons.dart';
import 'package:bike_control/widgets/keymap_explanation.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/services.dart';
@@ -83,7 +82,7 @@ class AndroidActions extends BaseActions {
: "up"}",
);
}
return NotHandled('No action assigned for ${button.toString().splitByUpperCase()}');
return NotHandled('No action assigned for ${button.name}');
}
void ignoreHidDevices() {

View File

@@ -164,4 +164,8 @@ class IAPManager {
_windowsIapService?.reset();
_iapService?.reset(fullReset);
}
Future<void> redeem() async {
await _iapService!.redeem();
}
}

View File

@@ -7,10 +7,11 @@ import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/main.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:dartx/dartx.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:intl/intl.dart';
import 'package:ios_receipt/ios_receipt.dart';
import 'package:version/version.dart';
@@ -22,6 +23,8 @@ class IAPService {
static const String _purchaseStatusKey = 'iap_purchase_status';
static const String _dailyCommandCountKey = 'iap_daily_command_count';
static const String _lastCommandDateKey = 'iap_last_command_date';
static const String _lastPurchaseCheckKey = 'iap_last_purchase_check';
static const String _hasPurchasedKey = 'iap_has_purchased';
final InAppPurchase _inAppPurchase = InAppPurchase.instance;
final FlutterSecureStorage _prefs;
@@ -71,6 +74,8 @@ class IAPService {
);
_trialStartDate = await _prefs.read(key: _trialStartDateKey);
core.connection.signalNotification(LogNotification('Trial start date: $_trialStartDate => $trialDaysRemaining'));
_lastCommandDate = await _prefs.read(key: _lastCommandDateKey);
final commandCount = await _prefs.read(key: _dailyCommandCountKey) ?? '0';
@@ -99,11 +104,25 @@ class IAPService {
Future<void> _checkExistingPurchase() async {
// First check if we have a stored purchase status
final storedStatus = await _prefs.read(key: _purchaseStatusKey);
final lastPurchaseCheck = await _prefs.read(key: _lastPurchaseCheckKey);
final hasPurchased = await _prefs.read(key: _hasPurchasedKey);
String todayDate = DateFormat('yMd').format(DateTime.now());
if (storedStatus == "true") {
IAPManager.instance.isPurchased.value = true;
if (Platform.isAndroid) {
if (lastPurchaseCheck == todayDate || hasPurchased == null) {
// hasPurchased means it was redeemed manually, so we skip the daily check
IAPManager.instance.isPurchased.value = true;
}
} else {
IAPManager.instance.isPurchased.value = true;
}
return;
}
await _prefs.write(key: _lastPurchaseCheckKey, value: todayDate);
// Platform-specific checks for existing paid app purchases
if (Platform.isIOS || Platform.isMacOS) {
// On iOS/macOS, check if the app was previously purchased (has a receipt)
@@ -125,14 +144,15 @@ class IAPService {
final receiptContent = await IosReceipt.getAppleReceipt();
if (receiptContent != null) {
debugPrint('Existing Apple user detected - validating receipt $receiptContent');
final sharedSecret =
var sharedSecret =
Platform.environment['VERIFYING_SHARED_SECRET'] ?? String.fromEnvironment("VERIFYING_SHARED_SECRET");
if (sharedSecret.isEmpty) {
sharedSecret = 'ac978d8af9f64db19fdbe6fbc494de2a';
core.connection.signalNotification(AlertNotification(LogLevel.LOGLEVEL_ERROR, 'Shared Secret is empty'));
}
core.connection.signalNotification(
LogNotification('Using shared secret: ${sharedSecret.characters.take(15).join()}'),
LogNotification('Using shared secret: ${sharedSecret.characters.take(10).join()}'),
);
await validateReceipt(
base64Receipt: receiptContent,
@@ -142,6 +162,7 @@ class IAPService {
debugPrint('No Apple receipt found');
}
} catch (e) {
core.connection.signalNotification(LogNotification('There was an error checking Apple receipt: ${e.toString()}'));
debugPrint('Error checking Apple receipt: $e');
}
}
@@ -195,13 +216,18 @@ class IAPService {
core.connection.signalNotification(
LogNotification('Apple receipt validated for version: $purchasedVersion'),
);
IAPManager.instance.isPurchased.value = Version.parse(purchasedVersion) < Version(4, 2, 0);
final purchasedVersionAsInt = int.tryParse(purchasedVersion.toString()) ?? 0;
IAPManager.instance.isPurchased.value = purchasedVersionAsInt < (Platform.isMacOS ? 61 : 58);
if (IAPManager.instance.isPurchased.value) {
debugPrint('Apple receipt validation successful - granting full access');
await _prefs.write(key: _purchaseStatusKey, value: "true");
} else {
debugPrint('Apple receipt validation failed - no full access');
}
} catch (e) {
rethrow;
} finally {
client.close();
}
@@ -215,6 +241,7 @@ class IAPService {
// while the app is still paid. Only users who downloaded the paid version will have
// a last_seen_version. After changing the app to free, new users won't have this set.
final lastSeenVersion = core.settings.getLastSeenVersion();
core.connection.signalNotification(LogNotification('Android last seen version: $lastSeenVersion'));
if (lastSeenVersion != null && lastSeenVersion.isNotEmpty) {
Version lastVersion = Version.parse(lastSeenVersion);
// If they had a previous version, they're an existing paid user
@@ -252,6 +279,8 @@ class IAPService {
);
if (purchase.status == PurchaseStatus.purchased || purchase.status == PurchaseStatus.restored) {
IAPManager.instance.isPurchased.value = !kDebugMode;
await _prefs.write(key: _hasPurchasedKey, value: "true");
await _prefs.write(key: _purchaseStatusKey, value: IAPManager.instance.isPurchased.value.toString());
debugPrint('Purchase successful or restored');
}
@@ -395,4 +424,9 @@ class IAPService {
initialize();
}
}
Future<void> redeem() async {
IAPManager.instance.isPurchased.value = true;
await _prefs.write(key: _purchaseStatusKey, value: IAPManager.instance.isPurchased.value.toString());
}
}

View File

@@ -60,8 +60,18 @@ class WindowsIAPService {
}
final trial = await _windowsIapPlugin.getTrialStatusAndRemainingDays();
core.connection.signalNotification(LogNotification('Trial status: $trial'));
trialDaysRemaining = trial.remainingDays;
if (trial.isActive && !trial.isTrial && trial.remainingDays <= 0) {
final trialEndDate = trial.remainingDays;
if (trial.isTrial && trialEndDate.isNotEmpty && !trialEndDate.contains("?")) {
try {
trialDaysRemaining = DateTime.parse(trialEndDate).difference(DateTime.now()).inDays;
} catch (e) {
core.connection.signalNotification(LogNotification('Error parsing trial end date: $e'));
trialDaysRemaining = 0;
}
} else {
trialDaysRemaining = 0;
}
if (trial.isActive && !trial.isTrial && trialDaysRemaining <= 0) {
IAPManager.instance.isPurchased.value = true;
await _prefs.write(key: _purchaseStatusKey, value: "true");
} else {

View File

@@ -7,6 +7,7 @@ import 'package:bike_control/utils/actions/android.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/requirements/platform.dart';
import 'package:bike_control/widgets/accessibility_disclosure_dialog.dart';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
@@ -222,8 +223,14 @@ class NotificationRequirement extends PlatformRequirement {
'Keep Alive',
actions: [
AndroidNotificationAction(
'disconnect',
AppLocalizations.current.disconnectDevices,
AppLocalizations.current.disconnectDevices,
cancelNotification: true,
showsUserInterface: false,
),
AndroidNotificationAction(
'close',
AppLocalizations.current.close,
cancelNotification: true,
showsUserInterface: false,
),
@@ -234,9 +241,18 @@ class NotificationRequirement extends PlatformRequirement {
final receivePort = ReceivePort();
IsolateNameServer.registerPortWithName(receivePort.sendPort, '_backgroundChannelKey');
final backgroundMessagePort = receivePort.asBroadcastStream();
backgroundMessagePort.listen((_) {
UniversalBle.onAvailabilityChange = null;
core.connection.reset();
backgroundMessagePort.listen((message) {
if (message == 'disconnect' || message == 'close') {
UniversalBle.onAvailabilityChange = null;
core.connection.disconnectAll();
}
if (message == 'close') {
core.connection.stop();
SystemNavigator.pop();
AndroidFlutterLocalNotificationsPlugin().stopForegroundService();
AndroidFlutterLocalNotificationsPlugin().cancelAll();
}
//exit(0);
});
}
@@ -246,7 +262,7 @@ class NotificationRequirement extends PlatformRequirement {
void notificationTapBackground(NotificationResponse notificationResponse) {
if (notificationResponse.actionId != null) {
final sendPort = IsolateNameServer.lookupPortByName('_backgroundChannelKey');
sendPort?.send('notificationResponse');
sendPort?.send(notificationResponse.actionId);
//exit(0);
}
}

View File

@@ -1,11 +1,16 @@
import 'dart:convert';
import 'dart:io';
import 'package:bike_control/bluetooth/messages/notification.dart';
import 'package:bike_control/gen/l10n.dart';
import 'package:bike_control/utils/core.dart';
import 'package:bike_control/utils/i18n_extension.dart';
import 'package:bike_control/utils/iap/iap_manager.dart';
import 'package:bike_control/widgets/ui/small_progress_indicator.dart';
import 'package:bike_control/widgets/ui/toast.dart';
import 'package:http/http.dart' as http;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:url_launcher/url_launcher_string.dart';
/// Widget to display IAP status and allow purchases
class IAPStatusWidget extends StatefulWidget {
@@ -16,9 +21,16 @@ class IAPStatusWidget extends StatefulWidget {
State<IAPStatusWidget> createState() => _IAPStatusWidgetState();
}
final _normalDate = DateTime(2026, 1, 15, 0, 0, 0, 0, 0);
class _IAPStatusWidgetState extends State<IAPStatusWidget> {
bool _isPurchasing = false;
bool _isSmall = false;
bool? _alreadyBoughtQuestion = null;
final _purchaseIdField = const TextFieldKey(#purchaseId);
bool _isLoading = false;
@override
void initState() {
@@ -54,7 +66,15 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
_isSmall = false;
});
}
: _handlePurchase,
: () {
if (Platform.isAndroid) {
if (_alreadyBoughtQuestion == false) {
_handlePurchase();
}
} else {
_handlePurchase();
}
},
style: ButtonStyle.card().withBackgroundColor(
color: Theme.of(context).colorScheme.muted,
hoverColor: Theme.of(context).colorScheme.primaryForeground,
@@ -162,29 +182,175 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
),
],
if (!IAPManager.instance.isPurchased.value && !_isSmall) ...[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.only(left: 42.0),
child: PrimaryButton(
onPressed: _isPurchasing ? null : _handlePurchase,
leading: Icon(Icons.star),
child: _isPurchasing
? Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
if (Platform.isAndroid)
Padding(
padding: const EdgeInsets.only(left: 42.0, top: 16.0),
child: Column(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(),
const SizedBox(),
if (_alreadyBoughtQuestion == null && DateTime.now().isBefore(_normalDate)) ...[
Text(AppLocalizations.of(context).alreadyBoughtTheAppPreviously).small,
Row(
children: [
SmallProgressIndicator(),
OutlineButton(
child: Text(AppLocalizations.of(context).yes),
onPressed: () {
setState(() {
_alreadyBoughtQuestion = true;
});
},
),
const SizedBox(width: 8),
Text('Processing...'),
OutlineButton(
child: Text(AppLocalizations.of(context).no),
onPressed: () {
setState(() {
_alreadyBoughtQuestion = false;
});
},
),
],
)
: Text(AppLocalizations.of(context).unlockFullVersion),
),
] else if (_alreadyBoughtQuestion == true) ...[
Text(
AppLocalizations.of(context).alreadyBoughtTheApp,
).small,
Form(
onSubmit: (context, values) async {
String purchaseId = _purchaseIdField[values]!;
setState(() {
_isLoading = true;
});
final redeemed = await _redeemPurchase(
purchaseId: purchaseId,
supabaseAnonKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBpa3JjeXlub3Zkdm9ncmxkZm53Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjYwNjMyMzksImV4cCI6MjA4MTYzOTIzOX0.oxJovYahRiZ6XvCVR-qww6OQ5jY6cjOyUiFHJsW9MVk',
supabaseUrl: 'https://pikrcyynovdvogrldfnw.supabase.co',
);
if (redeemed) {
await IAPManager.instance.redeem();
buildToast(context, title: 'Success', subtitle: 'Purchase redeemed successfully!');
setState(() {
_isLoading = false;
});
} else {
setState(() {
_isLoading = false;
});
if (mounted) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('Error'),
content: Text(
'Failed to redeem purchase. Please check your Purchase ID and try again or contact me directly. Sorry about that!',
),
actions: [
OutlineButton(
child: Text(context.i18n.getSupport),
onPressed: () {
launchUrlString(
'mailto:jonas@bikecontrol.app?subject=Bike%20Control%20Purchase%20Redemption%20Help',
);
},
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text('OK'),
),
],
);
},
);
}
}
},
child: Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: FormField(
showErrors: {
FormValidationMode.submitted,
FormValidationMode.changed,
},
key: _purchaseIdField,
label: Text('Purchase ID'),
validator: RegexValidator(
RegExp(r'GPA.[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{5}'),
message: 'Please enter a valid Purchase ID.',
),
child: TextField(
placeholder: Text('GPA.****-****-****-*****'),
),
),
),
FormErrorBuilder(
builder: (context, errors, child) {
return PrimaryButton(
onPressed: errors.isEmpty ? () => context.submitForm() : null,
child: _isLoading
? SmallProgressIndicator(color: Colors.black)
: const Text('Submit'),
);
},
),
],
),
),
] else if (_alreadyBoughtQuestion == false) ...[
PrimaryButton(
onPressed: _isPurchasing ? null : _handlePurchase,
leading: Icon(Icons.star),
child: _isPurchasing
? Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SmallProgressIndicator(),
const SizedBox(width: 8),
Text('Processing...'),
],
)
: Text(AppLocalizations.of(context).unlockFullVersion),
),
Text(AppLocalizations.of(context).fullVersionDescription).xSmall,
],
],
),
)
else ...[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.only(left: 42.0),
child: PrimaryButton(
onPressed: _isPurchasing ? null : _handlePurchase,
leading: Icon(Icons.star),
child: _isPurchasing
? Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SmallProgressIndicator(),
const SizedBox(width: 8),
Text('Processing...'),
],
)
: Text(AppLocalizations.of(context).unlockFullVersion),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 42.0, top: 8.0),
child: Text(AppLocalizations.of(context).fullVersionDescription).xSmall,
),
Padding(
padding: const EdgeInsets.only(left: 42.0, top: 8.0),
child: Text(AppLocalizations.of(context).fullVersionDescription).xSmall,
),
],
],
],
);
@@ -217,4 +383,36 @@ class _IAPStatusWidgetState extends State<IAPStatusWidget> {
}
}
}
Future<bool> _redeemPurchase({
required String supabaseUrl,
required String supabaseAnonKey,
required String purchaseId,
}) async {
final uri = Uri.parse(
'$supabaseUrl/functions/v1/redeem-purchase',
);
final response = await http.post(
uri,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $supabaseAnonKey',
},
body: jsonEncode({
'purchaseId': purchaseId,
}),
);
if (response.statusCode != 200) {
return false;
}
final body = response.body;
final decoded = jsonDecode(body) as Map<String, dynamic>;
core.connection.signalNotification(LogNotification(body));
return decoded['success'] == true;
}
}

View File

@@ -158,11 +158,11 @@ class _AppTitleState extends State<AppTitle> {
title: AppLocalizations.current.forceCloseToUpdate,
closeTitle: AppLocalizations.current.restart,
onClose: () {
core.connection.disconnectAll();
core.connection.stop();
if (Platform.isIOS) {
core.connection.reset();
Restart.restartApp(delayBeforeRestart: 1000);
} else {
core.connection.reset();
exit(0);
}
},

View File

@@ -1,7 +1,7 @@
name: bike_control
description: "BikeControl - Control your virtual riding"
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 4.2.2+62
version: 4.2.2+64
environment:
sdk: ^3.9.0

View File

@@ -1,6 +1,6 @@
class Trial {
final bool isTrial;
final int remainingDays;
final String remainingDays;
final bool isActive;
final bool isTrialOwnedByThisUser;

View File

@@ -112,7 +112,8 @@ class MethodChannelWindowsIap extends WindowsIapPlatform {
isActive: result?['isActive'] as bool? ?? false,
isTrialOwnedByThisUser:
result?['isTrialOwnedByThisUser'] as bool? ?? false,
remainingDays: result?['remainingDays'] as int? ?? 0,
remainingDays: result?['remainingDays'] as String? ??
DateTime.now().add(Duration(days: 7)).toString(),
);
}

View File

@@ -15,6 +15,8 @@
#include <winrt/Windows.Foundation.Collections.h>
#include <shobjidl.h>
#include <chrono>
#include <iomanip>
#include <flutter/event_sink.h>
#include <flutter/event_channel.h>
#include <flutter/event_stream_handler.h>
@@ -267,7 +269,7 @@ namespace windows_iap
flutter::EncodableMap result;
result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(true);
result[flutter::EncodableValue("remainingDays")] = flutter::EncodableValue(0);
result[flutter::EncodableValue("remainingDays")] = flutter::EncodableValue("");
result[flutter::EncodableValue("isActive")] = flutter::EncodableValue(license.IsActive());
result[flutter::EncodableValue("isTrialOwnedByThisUser")] = flutter::EncodableValue(license.IsTrialOwnedByThisUser());
@@ -282,10 +284,24 @@ namespace windows_iap
{
result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(true);
winrt::Windows::Foundation::TimeSpan expiration = license.TrialTimeRemaining();
const auto inDays = std::chrono::duration_cast<std::chrono::hours>(expiration).count() / 24.0;
auto expirationDate = license.ExpirationDate();
// dt is your winrt::Windows::Foundation::DateTime
std::time_t t = winrt::clock::to_time_t(expirationDate); // Convert to time_t (UTC seconds since 1970)
std::tm tm_buf;
localtime_s(&tm_buf, &t); // Safe version
std::wstringstream wss;
wss << std::put_time(&tm_buf, L"%Y-%m-%d %H:%M:%S"); // Custom format
winrt::hstring readable = winrt::hstring{ wss.str() };
std::string utf8 = winrt::to_string(readable); // Converts hstring to UTF-8 std::string
result[flutter::EncodableValue("remainingDays")] = flutter::EncodableValue(utf8);
}
else {
result[flutter::EncodableValue("isTrial")] = flutter::EncodableValue(false);
result[flutter::EncodableValue("remainingDays")] = flutter::EncodableValue(inDays);
}
resultCallback->Success(flutter::EncodableValue(result));