diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..789f775 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,69 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '15.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + + # permission_handler compiles only the permissions enabled here. OpenMotion + # needs Bluetooth only; every other permission is compiled out so App Store + # review does not see unused privacy-sensitive APIs. + target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + 'PERMISSION_BLUETOOTH=1', + 'PERMISSION_EVENTS=0', + 'PERMISSION_EVENTS_FULL_ACCESS=0', + 'PERMISSION_REMINDERS=0', + 'PERMISSION_CONTACTS=0', + 'PERMISSION_CAMERA=0', + 'PERMISSION_MICROPHONE=0', + 'PERMISSION_SPEECH_RECOGNIZER=0', + 'PERMISSION_PHOTOS=0', + 'PERMISSION_LOCATION=0', + 'PERMISSION_LOCATION_WHENINUSE=0', + 'PERMISSION_NOTIFICATIONS=0', + 'PERMISSION_MEDIA_LIBRARY=0', + 'PERMISSION_SENSORS=0', + 'PERMISSION_APP_TRACKING_TRANSPARENCY=0', + 'PERMISSION_CRITICAL_ALERTS=0', + 'PERMISSION_ASSISTANT=0', + ] + end + end +end diff --git a/lib/screens/scan_screen.dart b/lib/screens/scan_screen.dart index c90c444..df99a20 100644 --- a/lib/screens/scan_screen.dart +++ b/lib/screens/scan_screen.dart @@ -2,7 +2,11 @@ import 'package:flutter/material.dart'; import '../models/scooter_device.dart'; import '../scooters/apollo_scooter.dart'; +import 'dart:async'; +import 'dart:io'; + import '../services/ble_client.dart'; +import '../services/ble_permissions.dart'; import '../services/demo_ble_client.dart'; import '../theme.dart'; import 'scooter_screen.dart'; @@ -16,8 +20,14 @@ class ScanScreen extends StatefulWidget { State createState() => _ScanScreenState(); } -class _ScanScreenState extends State { +/// What is stopping us from scanning, if anything. +enum _Gate { checking, permissionDenied, permissionPermanentlyDenied, bluetoothOff, unsupported, ready } + +class _ScanScreenState extends State with WidgetsBindingObserver { Stream>? _scan; + _Gate _gate = _Gate.checking; + StreamSubscription? _adapterSub; + bool _requesting = false; /// Development path: list every BLE device so a scooter that does not /// advertise F1F0/F2F0 can still be selected and classified after GATT @@ -27,10 +37,86 @@ class _ScanScreenState extends State { @override void initState() { super.initState(); - _scan = widget.ble.scan(); + WidgetsBinding.instance.addObserver(this); + _prepare(request: true); } - void _restart() => setState(() => _scan = widget.ble.scan()); + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _adapterSub?.cancel(); + super.dispose(); + } + + /// Re-check when coming back from system settings or the Bluetooth toggle. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && _gate != _Gate.ready) { + _prepare(request: false); + } + } + + /// Permission first, then adapter state, then scan. + Future _prepare({required bool request}) async { + if (_requesting) return; + _requesting = true; + try { + final access = request ? await BlePermissions.request() : await BlePermissions.status(); + if (!mounted) return; + switch (access) { + case BleAccess.denied: + _setGate(_Gate.permissionDenied); + return; + case BleAccess.permanentlyDenied: + _setGate(_Gate.permissionPermanentlyDenied); + return; + case BleAccess.unsupported: + _setGate(_Gate.unsupported); + return; + case BleAccess.granted: + break; + } + _watchAdapter(); + } finally { + _requesting = false; + } + } + + void _watchAdapter() { + _adapterSub ??= widget.ble.adapterState.listen((s) { + if (!mounted) return; + switch (s) { + case BleAdapterState.on: + if (_gate != _Gate.ready) { + _setGate(_Gate.ready); + _restart(); + } + case BleAdapterState.off: + case BleAdapterState.unknown: + _setGate(_Gate.bluetoothOff); + case BleAdapterState.unauthorized: + _setGate(_Gate.permissionPermanentlyDenied); + case BleAdapterState.unsupported: + _setGate(_Gate.unsupported); + } + }); + } + + void _setGate(_Gate g) { + if (_gate == g) return; + setState(() { + _gate = g; + if (g != _Gate.ready) _scan = null; + }); + } + + void _restart() { + if (_gate != _Gate.ready) { + _prepare(request: true); + return; + } + setState(() => _scan = widget.ble.scan()); + } Future _connect(ScooterDevice device, {BleClient? ble}) async { setState(() => _scan = null); @@ -89,7 +175,9 @@ class _ScanScreenState extends State { ), ), Expanded( - child: _scan == null + child: _gate != _Gate.ready + ? _gateView() + : _scan == null ? const SizedBox.shrink() : StreamBuilder>( stream: _scan, @@ -152,6 +240,54 @@ class _ScanScreenState extends State { } } +extension on _ScanScreenState { + Widget _gateView() { + final android = Platform.isAndroid; + return switch (_gate) { + _Gate.checking => const Center(child: CircularProgressIndicator()), + _Gate.permissionDenied => _Empty( + icon: Icons.bluetooth_rounded, + title: 'Bluetooth permission needed', + message: android + ? 'OpenMotion needs the Nearby devices permission to find and talk to your scooter. ' + 'Scan results are never used for location.' + : 'OpenMotion needs Bluetooth access to find and talk to your scooter.', + action: FilledButton( + onPressed: () => _prepare(request: true), + child: const Text('Allow Bluetooth'), + ), + ), + _Gate.permissionPermanentlyDenied => _Empty( + icon: Icons.bluetooth_disabled_rounded, + title: 'Bluetooth permission blocked', + message: android + ? 'Nearby devices access was denied. Enable it for OpenMotion in system settings, then come back.' + : 'Bluetooth access is off for OpenMotion. Enable it in Settings, then come back.', + action: FilledButton( + onPressed: BlePermissions.openSettings, + child: const Text('Open settings'), + ), + ), + _Gate.bluetoothOff => _Empty( + icon: Icons.bluetooth_disabled_rounded, + title: 'Bluetooth is off', + message: android + ? 'Turn on Bluetooth to scan for your scooter.' + : 'Turn on Bluetooth in Control Center or Settings to scan for your scooter.', + action: android + ? FilledButton(onPressed: widget.ble.turnOn, child: const Text('Turn on Bluetooth')) + : null, + ), + _Gate.unsupported => const _Empty( + icon: Icons.error_outline_rounded, + title: 'Bluetooth unavailable', + message: 'This device does not support Bluetooth Low Energy.', + ), + _Gate.ready => const SizedBox.shrink(), + }; + } +} + class _DeviceCard extends StatelessWidget { const _DeviceCard({required this.device, required this.onConnect}); @@ -229,7 +365,8 @@ class _Empty extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( + return Container( + width: double.infinity, padding: const EdgeInsets.fromLTRB(24, 48, 24, 24), child: Column( children: [ diff --git a/lib/services/ble_client.dart b/lib/services/ble_client.dart index b07c111..b079c92 100644 --- a/lib/services/ble_client.dart +++ b/lib/services/ble_client.dart @@ -8,6 +8,8 @@ import 'protocol_log.dart'; enum BleConnectionState { disconnected, connected } +enum BleAdapterState { unknown, unsupported, off, on, unauthorized } + /// Thin boundary between OpenScooter and the underlying BLE plugin. /// /// OpenScooter v1 supports EXACTLY ONE active scooter connection at a time. @@ -17,6 +19,13 @@ enum BleConnectionState { disconnected, connected } /// Protocol code never imports the BLE plugin directly, so protocol tests run /// with `flutter test` and a fake subclass, without hardware. abstract class BleClient { + /// Current Bluetooth adapter state, emitting on change. Fakes default to on. + Stream get adapterState => Stream.value(BleAdapterState.on); + + /// Asks the OS to enable Bluetooth. Only Android supports this; elsewhere it + /// is a no-op and the UI must direct the user to system settings. + Future turnOn() async {} + /// Streams the current set of visible devices. Scanning starts on listen /// and stops when the subscription is cancelled. Stream> scan(); @@ -63,6 +72,27 @@ class FlutterBleClient extends BleClient { @override Stream get connectionState => _connState.stream; + @override + Stream get adapterState => + fbp.FlutterBluePlus.adapterState.map((s) => switch (s) { + fbp.BluetoothAdapterState.on => BleAdapterState.on, + fbp.BluetoothAdapterState.off || + fbp.BluetoothAdapterState.turningOff || + fbp.BluetoothAdapterState.turningOn => + BleAdapterState.off, + fbp.BluetoothAdapterState.unauthorized => BleAdapterState.unauthorized, + fbp.BluetoothAdapterState.unavailable => BleAdapterState.unsupported, + fbp.BluetoothAdapterState.unknown => BleAdapterState.unknown, + }); + + @override + Future turnOn() async { + if (defaultTargetPlatform == TargetPlatform.android) { + _log('ADAPTER turnOn requested'); + await fbp.FlutterBluePlus.turnOn(); + } + } + @override Stream> scan() { late StreamController> controller; @@ -70,12 +100,7 @@ class FlutterBleClient extends BleClient { Future start() async { try { - var adapter = await fbp.FlutterBluePlus.adapterState.first; - if (adapter != fbp.BluetoothAdapterState.on && - defaultTargetPlatform == TargetPlatform.android) { - await fbp.FlutterBluePlus.turnOn(); - } - adapter = await fbp.FlutterBluePlus.adapterState + await fbp.FlutterBluePlus.adapterState .where((s) => s == fbp.BluetoothAdapterState.on) .first .timeout(const Duration(seconds: 10)); diff --git a/lib/services/ble_permissions.dart b/lib/services/ble_permissions.dart new file mode 100644 index 0000000..c0734a6 --- /dev/null +++ b/lib/services/ble_permissions.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// Outcome of a Bluetooth permission check or request. +enum BleAccess { + /// Everything granted. Bluetooth adapter state is checked separately. + granted, + + /// The user declined but can be asked again. + denied, + + /// The user declined with "don't ask again" (Android) or in Settings (iOS). + /// Only the system settings screen can fix this. + permanentlyDenied, + + /// The platform reported Bluetooth as unavailable. + unsupported, +} + +/// Requests the runtime permissions OpenMotion needs to scan and connect. +/// +/// Android 12+ (API 31): BLUETOOTH_SCAN and BLUETOOTH_CONNECT, shown to the +/// user as "Nearby devices". Android 11 and below: legacy BLE scanning needs +/// location while in use. iOS: the single Bluetooth permission, which the OS +/// prompts for the first time CoreBluetooth is touched. +class BlePermissions { + BlePermissions._(); + + static int? _androidSdk; + + static Future androidSdk() async { + if (!Platform.isAndroid) return 0; + return _androidSdk ??= (await DeviceInfoPlugin().androidInfo).version.sdkInt; + } + + static Future> _needed() async { + if (Platform.isAndroid) { + final sdk = await androidSdk(); + return sdk >= 31 + ? const [Permission.bluetoothScan, Permission.bluetoothConnect] + : const [Permission.locationWhenInUse]; + } + if (Platform.isIOS || Platform.isMacOS) return const [Permission.bluetooth]; + return const []; + } + + /// Checks without prompting. + static Future status() async { + final statuses = []; + for (final p in await _needed()) { + statuses.add(await p.status); + } + return _combine(statuses); + } + + /// Prompts for anything not yet granted. + static Future request() async { + final needed = await _needed(); + if (needed.isEmpty) return BleAccess.granted; + final results = await needed.request(); + return _combine(results.values.toList()); + } + + static BleAccess _combine(List statuses) { + if (statuses.any((s) => s.isPermanentlyDenied || s.isRestricted)) { + return BleAccess.permanentlyDenied; + } + if (statuses.any((s) => s.isDenied)) return BleAccess.denied; + return BleAccess.granted; + } + + static Future openSettings() => openAppSettings(); +} diff --git a/pubspec.lock b/pubspec.lock index f8f7a27..b73a04e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.15" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" + url: "https://pub.dev" + source: hosted + version: "13.2.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" + url: "https://pub.dev" + source: hosted + version: "8.1.0" fake_async: dependency: "direct dev" description: @@ -416,6 +432,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 + url: "https://pub.dev" + source: hosted + version: "12.0.3" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 + url: "https://pub.dev" + source: hosted + version: "9.6.1" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: ed86a61c190258fdd65de395ea0632822e3415c1faec38eae0c31b479c28a531 + url: "https://pub.dev" + source: hosted + version: "4.4.1" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" petitparser: dependency: transitive description: @@ -605,6 +669,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.4.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" + url: "https://pub.dev" + source: hosted + version: "3.0.3" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3a0f8fe..31689a1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,8 @@ dependencies: path_provider: ^2.1.6 flutter_secure_storage: ^11.2.0 shared_preferences: ^2.5.5 + permission_handler: ^12.0.0 + device_info_plus: ^13.2.0 dev_dependencies: flutter_test: