diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c41dbd92..bb74edfe 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -50,7 +50,7 @@ NSLocationAlwaysAndWhenInUseUsageDescription Application needs Your location to show nearby aircraft. NSLocationAlwaysUsageDescription - Application needs Your location to show nearby aircraft. + Application needs background location for Drone Radar feature. NSLocationWhenInUseUsageDescription Application needs Your location to show nearby aircraft. UIApplicationSupportsIndirectInputEvents diff --git a/lib/bloc/standards_cubit.dart b/lib/bloc/standards_cubit.dart index c566687e..49fb20b4 100644 --- a/lib/bloc/standards_cubit.dart +++ b/lib/bloc/standards_cubit.dart @@ -8,19 +8,25 @@ import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; class StandardsState { - bool androidSystem = false; - bool btLegacy = false; // bt4 - bool btExtended = false; // bt4 - bool wifiBeacon = false; - bool wifiNaN = false; // aware on android - bool btExtendedClaimed = false; // can be claimed but still not work + final bool androidSystem; + final bool btLegacy; // bt4 + final bool btExtended; // bt5 + final bool wifiBeacon; + final bool wifiNaN; // aware on android + final bool btExtendedClaimed; // can be claimed but still not work // if adv len is < 1000, we stronly suspect Bt long range scanning wont work - int maxAdvDataLen = 0; + final int maxAdvDataLen; // permissions - bool btEnabled = false; - bool locationEnabled = false; - bool notificationsEnabled = false; - bool internetAvailable = false; + final bool btEnabled; + final bool locationEnabled; + final bool backgroundLocationEnabled; + // true if user was asked about background location and cancelled request + final bool backgroundLocationDenied; + final bool notificationsEnabled; + final bool internetAvailable; + + static const logPlatformStandardsKey = 'logPlatformStandards'; + static const backgroundLocationDeniedKey = 'backgroundLocationDenied'; StandardsState({ required this.androidSystem, @@ -32,6 +38,8 @@ class StandardsState { required this.maxAdvDataLen, required this.btEnabled, required this.locationEnabled, + required this.backgroundLocationEnabled, + required this.backgroundLocationDenied, required this.notificationsEnabled, required this.internetAvailable, }); @@ -46,6 +54,8 @@ class StandardsState { int? maxAdvDataLen, bool? btEnabled, bool? locationEnabled, + bool? backgroundLocationEnabled, + bool? backgroundLocationDenied, bool? notificationsEnabled, bool? internetAvailable, }) => @@ -59,6 +69,10 @@ class StandardsState { maxAdvDataLen: maxAdvDataLen ?? this.maxAdvDataLen, btEnabled: btEnabled ?? this.btEnabled, locationEnabled: locationEnabled ?? this.locationEnabled, + backgroundLocationDenied: + backgroundLocationDenied ?? this.backgroundLocationDenied, + backgroundLocationEnabled: + backgroundLocationEnabled ?? this.backgroundLocationEnabled, notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled, internetAvailable: internetAvailable ?? this.internetAvailable, ); @@ -79,6 +93,8 @@ class StandardsCubit extends Cubit { internetAvailable: false, notificationsEnabled: false, locationEnabled: false, + backgroundLocationEnabled: false, + backgroundLocationDenied: false, ), ); @@ -128,7 +144,14 @@ class StandardsCubit extends Cubit { ); final preferences = await SharedPreferences.getInstance(); - final logPlatformStatus = preferences.getBool('logPlatformStandards'); + + final backgroundLocationDenied = + preferences.getBool(StandardsState.backgroundLocationDeniedKey) ?? + false; + emit(state.copyWith(backgroundLocationDenied: backgroundLocationDenied)); + + final logPlatformStatus = + preferences.getBool(StandardsState.logPlatformStandardsKey); // skip if info was already logged if (logPlatformStatus != null && !logPlatformStatus) { return; @@ -159,22 +182,28 @@ class StandardsCubit extends Cubit { Logger.root.info('maxAdvDataLen $maxAdvDataLen'); // set that platforms standards were already logged - await preferences.setBool('logPlatformStandards', true); + await preferences.setBool(StandardsState.logPlatformStandardsKey, true); } - Future setLocationEnabled({required bool enabled}) async { - emit(state.copyWith(locationEnabled: enabled)); - } + void setLocationEnabled({required bool enabled}) => + emit(state.copyWith(locationEnabled: enabled)); - Future setBluetoothEnabled({required bool enabled}) async { - emit(state.copyWith(btEnabled: enabled)); - } + void setBackgroundLocationEnabled({required bool enabled}) async => + emit(state.copyWith(backgroundLocationEnabled: enabled)); - Future setNotificationsEnabled({required bool enabled}) async { - emit(state.copyWith(notificationsEnabled: enabled)); + Future setBackgroundLocationDenied() async { + final preferences = await SharedPreferences.getInstance(); + preferences.setBool(StandardsState.backgroundLocationDeniedKey, true); + emit(state.copyWith( + backgroundLocationEnabled: false, backgroundLocationDenied: true)); } - Future setInternetAvailable({required bool available}) async { - emit(state.copyWith(internetAvailable: available)); - } + void setBluetoothEnabled({required bool enabled}) => + emit(state.copyWith(btEnabled: enabled)); + + void setNotificationsEnabled({required bool enabled}) => + emit(state.copyWith(notificationsEnabled: enabled)); + + void setInternetAvailable({required bool available}) => + emit(state.copyWith(internetAvailable: available)); } diff --git a/lib/widgets/app/dialogs.dart b/lib/widgets/app/dialogs.dart index 1ce05c23..77b3d3b3 100644 --- a/lib/widgets/app/dialogs.dart +++ b/lib/widgets/app/dialogs.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:another_flushbar/flushbar.dart'; import 'package:app_settings/app_settings.dart'; import 'package:flutter/material.dart'; @@ -48,25 +50,7 @@ Future showLocationPermissionDialog({ required bool showWhileUsingPermissionExplanation, }) async { // set up the buttons - final Widget cancelButton = TextButton( - child: const Text('Cancel'), - onPressed: () { - Navigator.pop(context, false); - }, - ); - final Widget appSettingsButton = TextButton( - child: const Text('App settings'), - onPressed: () { - Navigator.pop(context, false); - AppSettings.openAppSettings(); - }, - ); - final Widget continueButton = TextButton( - child: const Text('Continue'), - onPressed: () { - Navigator.pop(context, true); - }, - ); + final actions = _getPermissionDialogActions(context); // set up the AlertDialog final alert = AlertDialog( title: const Text('Location permission required'), @@ -82,7 +66,8 @@ Future showLocationPermissionDialog({ style: TextStyle(fontWeight: FontWeight.w700), ), const TextSpan( - text: 'option to enable scans in the background.\n\n', + text: + 'option to enable scans while the app is in foreground.\n\n', ), ], const TextSpan( @@ -96,10 +81,49 @@ Future showLocationPermissionDialog({ ], ), ), + actions: actions.values.toList(), + ); + // show the dialog + final result = await showDialog( + context: context, + builder: (context) { + return alert; + }, + ); + return result ?? false; +} + +Future showBackgroundPermissionDialog({ + required BuildContext context, +}) async { + // set up the buttons + final actions = _getPermissionDialogActions(context); + final isAndroid = Platform.isAndroid; + // set up the AlertDialog + final alert = AlertDialog( + title: const Text('Background location permission'), + content: const Text.rich( + TextSpan( + text: 'Drone Scanner requires background location permission to scan ' + 'for nearby aircraft while in the background.\n\n', + children: [ + TextSpan( + text: 'If you wish to use the Drone Radar feature or gather data ' + 'while the app is minimized, please select\nthe '), + TextSpan( + text: '"Allow all the time"\n', + style: TextStyle(fontWeight: FontWeight.w700), + ), + TextSpan( + text: 'option in application settings.\n\n', + ), + ], + ), + ), actions: [ - cancelButton, - appSettingsButton, - continueButton, + actions['cancel']!, + if (isAndroid) actions['continue']!, + if (!isAndroid) actions['appSettings']!, ], ); // show the dialog @@ -112,6 +136,30 @@ Future showLocationPermissionDialog({ return result ?? false; } +Map _getPermissionDialogActions(BuildContext context) { + return { + 'cancel': TextButton( + child: const Text('Cancel'), + onPressed: () { + Navigator.pop(context, false); + }, + ), + 'appSettings': TextButton( + child: const Text('App settings'), + onPressed: () { + Navigator.pop(context, true); + AppSettings.openAppSettings(); + }, + ), + 'continue': TextButton( + child: const Text('Continue'), + onPressed: () { + Navigator.pop(context, true); + }, + ), + }; +} + void showSnackBar( BuildContext context, String snackBarText, { diff --git a/lib/widgets/app/life_cycle_manager.dart b/lib/widgets/app/life_cycle_manager.dart index a9980229..bc277384 100644 --- a/lib/widgets/app/life_cycle_manager.dart +++ b/lib/widgets/app/life_cycle_manager.dart @@ -32,8 +32,8 @@ class _LifeCycleManagerState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { - checkPermissions(); - Future.delayed(const Duration(seconds: 5), checkInternetConnection); + _checkPermissions(); + Future.delayed(const Duration(seconds: 5), _checkInternetConnection); final alertsState = context.read().state; if (alertsState.proximityAlertActive && alertsState.hasRecentAlerts()) { Future.delayed(const Duration(seconds: 1), @@ -53,13 +53,13 @@ class _LifeCycleManagerState extends State showcaseState.showcaseActive) { showcaseSub = context.read().stream.listen((event) { if (event is ShowcaseStateInitialized && !event.showcaseActive) { - initPlatformState(); + _initPlatformState(); showcaseSub?.cancel(); } }); return; } else { - initPlatformState(); + _initPlatformState(); } }, ); @@ -70,33 +70,24 @@ class _LifeCycleManagerState extends State super.didChangeDependencies(); } - Future initPlatformState() async { - if (Platform.isAndroid) { - await _initPermissionsAndroid(context); - } else if (Platform.isIOS) { - await _initPermissionsIOS(context); - } else { - return; - } - if (!mounted) return; - if (context.read().state.showcaseActive) { - await context.read().stop(); - } - // check internet connection - try { - final result = await InternetAddress.lookup('example.com'); - if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { - if (!mounted) return; - await context - .read() - .setInternetAvailable(available: true); - } - } on SocketException catch (_) { - if (!mounted) return; - await context - .read() - .setInternetAvailable(available: false); - } + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + showcaseSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + child: widget.child, + ); } Future _initPermissionsIOS(BuildContext context) async { @@ -106,7 +97,7 @@ class _LifeCycleManagerState extends State if (btStatus.isGranted) { if (!mounted) return; - await standardsCubit.setBluetoothEnabled(enabled: true); + standardsCubit.setBluetoothEnabled(enabled: true); if (!mounted) return; final btTurnedOn = await odidCubit.isBtTurnedOn(); @@ -115,22 +106,22 @@ class _LifeCycleManagerState extends State } } else { if (!mounted) return; - await standardsCubit.setBluetoothEnabled(enabled: false); + standardsCubit.setBluetoothEnabled(enabled: false); } final status = await Permission.location.request(); if (status.isGranted) { - initLocation(); + _initLocation(); if (!mounted) return; - await standardsCubit.setLocationEnabled(enabled: true); + standardsCubit.setLocationEnabled(enabled: true); } else { if (!mounted) return; - await standardsCubit.setLocationEnabled(enabled: false); + standardsCubit.setLocationEnabled(enabled: false); } final notificationStatus = await Permission.notification.request(); if (notificationStatus.isGranted) { - await standardsCubit.setNotificationsEnabled(enabled: true); + standardsCubit.setNotificationsEnabled(enabled: true); } } @@ -138,40 +129,40 @@ class _LifeCycleManagerState extends State final odidCubit = context.read(); final standardsCubit = context.read(); - final version = await getAndroidVersionNumber(); + final version = await _getAndroidVersionNumber(); if (version == null) return; final locStatus = await Permission.location.status; // show dialog before asking for location // when already granted or pernamently denied, request is not needed - if (!(locStatus.isGranted || locStatus.isPermanentlyDenied) && - context.mounted) { + if (!(locStatus.isGranted) && context.mounted) { if (await showLocationPermissionDialog( context: context, showWhileUsingPermissionExplanation: version >= 11, )) { final status = await Permission.location.request(); if (status.isDenied) { - await standardsCubit.setLocationEnabled(enabled: false); + standardsCubit.setLocationEnabled(enabled: false); } else { - initLocation(); - await standardsCubit.setLocationEnabled(enabled: true); + _initLocation(); + standardsCubit.setLocationEnabled(enabled: true); } } } else if (locStatus.isGranted) { - initLocation(); - await standardsCubit.setLocationEnabled(enabled: true); + _initLocation(); + standardsCubit.setLocationEnabled(enabled: true); } + final btStatus = await Permission.bluetooth.request(); // scan makes sense just on android final btScanStatus = await Permission.bluetoothScan.request(); if (btStatus.isGranted && btScanStatus.isGranted) { - await standardsCubit.setBluetoothEnabled(enabled: true); + standardsCubit.setBluetoothEnabled(enabled: true); final btTurnedOn = await odidCubit.isBtTurnedOn(); if (btTurnedOn) { await odidCubit.setBtUsed(btUsed: true); } } else { - await standardsCubit.setBluetoothEnabled(enabled: false); + standardsCubit.setBluetoothEnabled(enabled: false); } if (!mounted) { return; @@ -188,44 +179,100 @@ class _LifeCycleManagerState extends State .resolvePlatformSpecificImplementation< AndroidFlutterLocalNotificationsPlugin>() ?.requestPermission(); - await standardsCubit.setNotificationsEnabled(enabled: result ?? false); + standardsCubit.setNotificationsEnabled(enabled: result ?? false); + } + + Future _initBackgroundLocationPermission(BuildContext context) async { + final standardsCubit = context.read(); + final locStatus = await Permission.location.status; + + final backgroundLocStatus = await Permission.locationAlways.status; + // do not ask for locationAlways if location is not granted + if (!locStatus.isGranted) return; + if ((backgroundLocStatus.isGranted)) { + standardsCubit.setBackgroundLocationEnabled(enabled: true); + return; + } + + if (context.mounted && !standardsCubit.state.backgroundLocationDenied) { + // show dialog to ask user for background location permission + if (await showBackgroundPermissionDialog( + context: context, + )) { + final status = await Permission.locationAlways.request(); + + if (status.isGranted) { + if (!mounted) return; + standardsCubit.setBackgroundLocationEnabled(enabled: true); + } else { + if (!mounted) return; + standardsCubit.setBackgroundLocationEnabled(enabled: false); + } + } + // user denied background loc -> save response and do not ask again + else { + await standardsCubit.setBackgroundLocationDenied(); + } + } + } + + Future _initPlatformState() async { + if (Platform.isAndroid) { + await _initPermissionsAndroid(context); + } else if (Platform.isIOS) { + await _initPermissionsIOS(context); + } else { + return; + } + + if (!mounted) return; + + await _initBackgroundLocationPermission(context); + + if (!mounted) return; + if (context.read().state.showcaseActive) { + await context.read().stop(); + } + _checkInternetConnection(); } - Future getAndroidVersionNumber() async { + Future _getAndroidVersionNumber() async { final deviceInfo = DeviceInfoPlugin(); final androidVersion = (await deviceInfo.androidInfo).version.release; return int.tryParse(androidVersion); } - void initLocation() { + void _initLocation() { final location = Location(); - location.getLocation().then(userLocationChanged); - location.onLocationChanged.listen(userLocationChanged); + location.getLocation().then(_userLocationChanged); + location.onLocationChanged.listen(_userLocationChanged); } // check permission status without requests - Future checkPermissions() async { + Future _checkPermissions() async { final standardsCubit = context.read(); if (!mounted) return; final loc = await Permission.location.isGranted; // check loc, if was not set before, init listener if (loc && !standardsCubit.state.locationEnabled) { - initLocation(); + _initLocation(); } - await standardsCubit.setLocationEnabled(enabled: loc); + standardsCubit.setLocationEnabled(enabled: loc); + final backgroundLoc = await Permission.locationAlways.isGranted; + standardsCubit.setBackgroundLocationEnabled(enabled: backgroundLoc); final bt = await Permission.bluetooth.isGranted; if (Platform.isAndroid) { final btScan = await Permission.bluetoothScan.isGranted; - await standardsCubit.setBluetoothEnabled(enabled: bt && btScan); + standardsCubit.setBluetoothEnabled(enabled: bt && btScan); } else { - await standardsCubit.setBluetoothEnabled(enabled: bt); + standardsCubit.setBluetoothEnabled(enabled: bt); } - await standardsCubit.setNotificationsEnabled( + standardsCubit.setNotificationsEnabled( enabled: await Permission.notification.isGranted); } - void userLocationChanged(LocationData currentLocation) { + void _userLocationChanged(LocationData currentLocation) { void updateLoc() { final mapCubit = context.read(); if (currentLocation.latitude != null && @@ -249,42 +296,16 @@ class _LifeCycleManagerState extends State } } - Future checkInternetConnection() async { + Future _checkInternetConnection() async { // check internet + final standardsCubit = context.read(); try { final result = await InternetAddress.lookup('example.com'); if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { - if (!mounted) return; - await context - .read() - .setInternetAvailable(available: true); + standardsCubit.setInternetAvailable(available: true); } } on SocketException catch (_) { - if (mounted) { - await context - .read() - .setInternetAvailable(available: false); - } + standardsCubit.setInternetAvailable(available: false); } } - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - showcaseSub?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Container( - child: widget.child, - ); - } } diff --git a/lib/widgets/preferences/preferences_page.dart b/lib/widgets/preferences/preferences_page.dart index 034f1c25..48a7df72 100644 --- a/lib/widgets/preferences/preferences_page.dart +++ b/lib/widgets/preferences/preferences_page.dart @@ -319,6 +319,16 @@ class PreferencesPage extends StatelessWidget { icon: state.locationEnabled ? positiveIcon : negativeIcon, ), ), + Padding( + padding: itemPadding, + child: PreferencesField( + label: 'Background Location', + text: state.backgroundLocationEnabled ? 'Granted' : 'Not Granted', + color: + state.backgroundLocationEnabled ? AppColors.green : AppColors.red, + icon: state.backgroundLocationEnabled ? positiveIcon : negativeIcon, + ), + ), Padding( padding: itemPadding, child: PreferencesField(