From 3674572ea853c3a6557f10d655fac0c21a4d729d Mon Sep 17 00:00:00 2001 From: Miklos Havlik Date: Fri, 21 Aug 2026 11:21:03 +0200 Subject: [PATCH 1/3] fix(cams): ask for permissions once, before devices connect Devices connected before anyone asked for permissions, so connect failed its permission check and the executor stayed PausedButShouldBeResumed until the app was restarted. Move the ask above the connect. Permissions are now declared as data (`DeviceManager.permissions`) and requested in one place by an injectable `PermissionRequester`, replacing 5 hand-rolled `onRequestPermissions()` implementations and 3 separate request mechanisms. Also fixes the "allow alarms & reminders" dialog on first launch (notifications auto-degrade to inexact scheduling) and Android's location ladder (locationWhenInUse is now always asked before locationAlways). --- .gitignore | 3 + apps/carp_mobile_sensing_app/README.md | 5 +- apps/carp_mobile_sensing_app/lib/main.dart | 1 - apps/carp_mobile_sensing_app/lib/src/app.dart | 6 - .../lib/src/services/sensing.dart | 13 +- apps/carp_mobile_sensing_app/pubspec.yaml | 1 - backends/carp_backend/CHANGELOG.md | 4 + backends/carp_backend/pubspec.yaml | 6 +- backends/carp_webservices/CHANGELOG.md | 4 + .../carp_webservices/example/pubspec.yaml | 2 +- backends/carp_webservices/pubspec.yaml | 4 +- carp_mobile_sensing/CHANGELOG.md | 44 +++++ carp_mobile_sensing/example/lib/examples.dart | 6 +- carp_mobile_sensing/example/lib/main.dart | 11 -- .../domain/core/smartphone_deployment.dart | 5 + .../services/local_notification_manager.dart | 28 +-- carp_mobile_sensing/lib/runtime.dart | 1 + .../lib/runtime/client_manager.dart | 26 ++- .../device_manager/device_manager.dart | 35 +++- .../device_manager/device_managers.dart | 38 ++-- .../lib/runtime/executors/probes.dart | 93 ++------- .../lib/runtime/permissions.dart | 45 +++++ .../lib/runtime/study_controller.dart | 118 ++++-------- carp_mobile_sensing/pubspec.yaml | 4 +- .../test/permissions_test.dart | 180 ++++++++++++++++++ packages/carp_apps_package/CHANGELOG.md | 4 + packages/carp_apps_package/pubspec.yaml | 4 +- packages/carp_audio_package/CHANGELOG.md | 6 + .../carp_audio_package/lib/audio_probe.dart | 24 ++- packages/carp_audio_package/pubspec.yaml | 4 +- .../carp_communication_package/CHANGELOG.md | 4 + .../carp_communication_package/pubspec.yaml | 4 +- .../carp_connectivity_package/CHANGELOG.md | 4 + .../carp_connectivity_package/pubspec.yaml | 4 +- packages/carp_context_package/CHANGELOG.md | 12 ++ .../lib/src/context_package.dart | 12 +- .../lib/src/location/location_probes.dart | 40 ++-- .../lib/src/location/location_services.dart | 7 - .../lib/src/location_manager.dart | 73 +------ packages/carp_context_package/pubspec.yaml | 4 +- packages/carp_esense_package/CHANGELOG.md | 4 + packages/carp_esense_package/pubspec.yaml | 4 +- packages/carp_health_package/CHANGELOG.md | 6 + .../carp_health_package/lib/health_probe.dart | 1 - .../lib/health_service_manager.dart | 2 + packages/carp_health_package/pubspec.yaml | 4 +- packages/carp_movesense_package/CHANGELOG.md | 4 + packages/carp_movesense_package/pubspec.yaml | 4 +- packages/carp_movisens_package/CHANGELOG.md | 4 + packages/carp_movisens_package/pubspec.yaml | 4 +- packages/carp_polar_package/CHANGELOG.md | 4 + packages/carp_polar_package/pubspec.yaml | 4 +- packages/carp_survey_package/CHANGELOG.md | 4 + packages/carp_survey_package/pubspec.yaml | 4 +- 54 files changed, 564 insertions(+), 378 deletions(-) create mode 100644 carp_mobile_sensing/lib/runtime/permissions.dart create mode 100644 carp_mobile_sensing/test/permissions_test.dart diff --git a/.gitignore b/.gitignore index fe509b67f..6f7e28788 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ apps/carp_mobile_sensing_app/test/credentials.dart backends/carp_backend/test/credentials.dart apps/carp_mobile_sensing_app/lib/config.dart backends/carp_webservices/test/_credentials.dart + +# Local path overrides for developing against a sibling worktree - never committed. +pubspec_overrides.yaml diff --git a/apps/carp_mobile_sensing_app/README.md b/apps/carp_mobile_sensing_app/README.md index b2dcc8d5c..ec8ca9228 100644 --- a/apps/carp_mobile_sensing_app/README.md +++ b/apps/carp_mobile_sensing_app/README.md @@ -180,10 +180,7 @@ class Sensing { /// Initialize and set up sensing. Future initialize() async { // Configure the client manager with the deployment service specified based on deployment mode - await client.configure( - deploymentService: deploymentService, - askForPermissions: true, - ); + await client.configure(deploymentService: deploymentService); // Listen on the measurements stream and count measurements client.measurements.listen((measurement) => samplingSize++); diff --git a/apps/carp_mobile_sensing_app/lib/main.dart b/apps/carp_mobile_sensing_app/lib/main.dart index cfcc9a712..63e247099 100644 --- a/apps/carp_mobile_sensing_app/lib/main.dart +++ b/apps/carp_mobile_sensing_app/lib/main.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart' hide TimeOfDay; import 'package:flutter/services.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' as ble; import 'package:carp_serializable/carp_serializable.dart'; diff --git a/apps/carp_mobile_sensing_app/lib/src/app.dart b/apps/carp_mobile_sensing_app/lib/src/app.dart index c710dac20..c2340feb2 100644 --- a/apps/carp_mobile_sensing_app/lib/src/app.dart +++ b/apps/carp_mobile_sensing_app/lib/src/app.dart @@ -21,12 +21,6 @@ class LoadingPage extends StatelessWidget { /// /// Returns true when successfully done. Future init(BuildContext context) async { - // Request location "always" permissions upfront. - // Note that this is a two-step process on Android, where the user first has - // to grant "when in use" permissions, and then "always" permissions. - await Permission.locationWhenInUse.request(); - await Permission.locationAlways.request(); - // Initialize and use the CAWS backend if not in local deployment mode if (bloc.deploymentMode != DeploymentMode.local) { await CarpBackend().initialize(); diff --git a/apps/carp_mobile_sensing_app/lib/src/services/sensing.dart b/apps/carp_mobile_sensing_app/lib/src/services/sensing.dart index 509239b33..df2ef596e 100644 --- a/apps/carp_mobile_sensing_app/lib/src/services/sensing.dart +++ b/apps/carp_mobile_sensing_app/lib/src/services/sensing.dart @@ -82,10 +82,7 @@ class Sensing { info('Initializing $runtimeType - mode: ${bloc.deploymentMode}'); // Configure the client manager using the deployment service above (local or CAWS). - await client.configure( - deploymentService: deploymentService, - askForPermissions: true, - ); + await client.configure(deploymentService: deploymentService); // Listen on the measurements stream and count measurements and print them as they come in. client.measurements.listen((measurement) { @@ -107,13 +104,7 @@ class Sensing { ); // Resume the current [study]. - Future resume() async { - // Need to ask for permissions before resuming, otherwise the app may crash - // when trying to start sampling without permissions. - controller?.askForAllPermissions().then((_) async { - controller?.resume(); - }); - } + Future resume() async => controller?.resume(); // Pause the current [study]. Future pause() async => client.pause(); diff --git a/apps/carp_mobile_sensing_app/pubspec.yaml b/apps/carp_mobile_sensing_app/pubspec.yaml index 40538b7da..19e651b7c 100644 --- a/apps/carp_mobile_sensing_app/pubspec.yaml +++ b/apps/carp_mobile_sensing_app/pubspec.yaml @@ -44,7 +44,6 @@ dependencies: system_info2: ^4.0.0 path_provider: ^2.0.0 sqflite: ^2.2.8 # For local storage in SQLite DB - permission_handler: '>=11.0.0 <13.0.0' # For requesting permissions on Android and iOS shared_preferences: ^2.2.0 package_info_plus: ^10.2.1 flutter_local_notifications: ^21.0.0 # For sending notification on AppTask diff --git a/backends/carp_backend/CHANGELOG.md b/backends/carp_backend/CHANGELOG.md index e1e86facb..94fbd5e5c 100644 --- a/backends/carp_backend/CHANGELOG.md +++ b/backends/carp_backend/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0 + +* require `carp_mobile_sensing` ^3.0.0 + ## 2.1.1 - upgrade to `research_package` ^3.0.0 diff --git a/backends/carp_backend/pubspec.yaml b/backends/carp_backend/pubspec.yaml index 01e8b6966..d42271a19 100644 --- a/backends/carp_backend/pubspec.yaml +++ b/backends/carp_backend/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_backend -version: 2.1.1 +version: 3.0.0 description: CARP data backend for CARP Mobile Sensing. Supports downloading study deployments and uploading data from/to a CARP Web Service (CAWS) server. homepage: https://github.com/cph-cachet/carp.sensing-flutter repository: https://github.com/carp-dk/carp.sensing-flutter/tree/main/backends/carp_backend @@ -30,8 +30,8 @@ dependencies: carp_serializable: ^3.0.0 carp_core: ^2.2.0 - carp_mobile_sensing: ^2.3.0 - carp_webservices: ^4.2.0 + carp_mobile_sensing: ^3.0.0 + carp_webservices: ^5.0.0 research_package: ^3.0.0 diff --git a/backends/carp_webservices/CHANGELOG.md b/backends/carp_webservices/CHANGELOG.md index 1d42927a5..737962e75 100644 --- a/backends/carp_webservices/CHANGELOG.md +++ b/backends/carp_webservices/CHANGELOG.md @@ -1,3 +1,7 @@ +## 5.0.0 + +* require `carp_mobile_sensing` ^3.0.0 + ## 4.2.0 * require `carp_serializable` ^3.0.0, which replaces the built-in `Uuid` with the [uuid](https://pub.dev/packages/uuid) package diff --git a/backends/carp_webservices/example/pubspec.yaml b/backends/carp_webservices/example/pubspec.yaml index ba4de0007..b914dbebc 100644 --- a/backends/carp_webservices/example/pubspec.yaml +++ b/backends/carp_webservices/example/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: sdk: flutter carp_core: ^2.1.2 - carp_mobile_sensing: ^2.1.2 + carp_mobile_sensing: ^3.0.0 qr_code_scanner_plus: ^2.0.12 oidc: ^0.14.0 diff --git a/backends/carp_webservices/pubspec.yaml b/backends/carp_webservices/pubspec.yaml index 9c3072ad0..87e6ff19e 100644 --- a/backends/carp_webservices/pubspec.yaml +++ b/backends/carp_webservices/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_webservices -version: 4.2.0 +version: 5.0.0 description: Flutter API for accessing the CARP web services, including authentication, deployments, data, files, and collections of documents. homepage: https://github.com/cph-cachet/carp.sensing-flutter repository: https://github.com/carp-dk/carp.sensing-flutter/tree/main/backends/carp_webservices @@ -26,7 +26,7 @@ dependencies: carp_serializable: ^3.0.0 carp_core: ^2.2.0 - carp_mobile_sensing: ^2.2.0 + carp_mobile_sensing: ^3.0.0 http: ^1.6.0 json_annotation: ^4.12.0 diff --git a/carp_mobile_sensing/CHANGELOG.md b/carp_mobile_sensing/CHANGELOG.md index 08b53f707..f29c24cb2 100644 --- a/carp_mobile_sensing/CHANGELOG.md +++ b/carp_mobile_sensing/CHANGELOG.md @@ -1,3 +1,47 @@ +## 3.0.0 + +Permissions are now declared as data and requested in one place, before devices connect. + +**Fixes** + +* no more "allow alarms & reminders" dialog on first launch - notifications are scheduled + exactly when `SCHEDULE_EXACT_ALARM` happens to be granted, and inexactly (still delivered + while the phone is idle, within minutes) when it is not. Drop `SCHEDULE_EXACT_ALARM` from + your manifest unless your study truly needs to-the-second reminders +* the Android location ladder works: `locationAlways` is asked only after `locationWhenInUse`, + in its own dialog, whatever order a study declares them in +* permissions are requested *before* devices connect. Previously devices connected first, + failed their permission check, and stayed paused until the app was restarted. + Only the devices actually being connected are asked about - a wearable the participant + has not paired yet asks for its own permissions when it is paired +* `Permission.notification` is asked for when a study with notifying app tasks starts, + instead of at `configure()` before any study exists + +**Breaking** + +* `DeviceManager.onRequestPermissions()` -> `List get permissions`: + + ```dart + // before + Future onRequestPermissions() async => await Permission.sensors.request(); + // after + List get permissions => [Permission.sensors]; + ``` + + `onHasPermissions()` now checks these by default - override it only to require a subset. + Devices not using `permission_handler` (e.g. Health Connect) can still override + `onRequestPermissions()` +* `SmartPhoneClientManager.configure(askForPermissions: bool)` -> + `configure(permissionRequester: PermissionRequester?)`. The default asks one dialog at a + time; pass your own to show a rationale first, or `null` to handle permissions in the app +* `SmartphoneStudyController.askForAllPermissions()` removed - CAMS asks automatically. + The new `requiredPermissions` getter lists what a deployment needs, so an app can explain + it up front +* `SmartphoneStudyController.permissions` removed - it cached a status the OS can revoke at + any time. Ask `permission_handler` instead +* `Probe.requestPermissions()` and `Probe.arePermissionsGranted()` removed - probes check + via `hasRequiredPermissions()` and never ask + ## 2.3.1 * fix `duplicate column name: record_id` crash in the `record_id` SQLite migration (`SQLiteDataManager.onUpgrade`) by only adding the column/index when it isn't already there diff --git a/carp_mobile_sensing/example/lib/examples.dart b/carp_mobile_sensing/example/lib/examples.dart index b1eb65ac3..6b3fd656a 100644 --- a/carp_mobile_sensing/example/lib/examples.dart +++ b/carp_mobile_sensing/example/lib/examples.dart @@ -452,12 +452,12 @@ void example_3() async { // * [FlutterLocalNotificationController] // * [SmartphoneDeploymentService] // * [DeviceController] - // * asking for permissions // * notifications enabled + // * asking for the permissions a study needs, one dialog at a time await client.configure(); - // disabling notifications and permissions handling - await client.configure(enableNotifications: false, askForPermissions: false); + // disabling notifications, and handling permissions in the app instead + await client.configure(enableNotifications: false, permissionRequester: null); // add and deploy the protocol final study = await client.addStudyFromProtocol(protocol); diff --git a/carp_mobile_sensing/example/lib/main.dart b/carp_mobile_sensing/example/lib/main.dart index d08381525..b053dddd2 100644 --- a/carp_mobile_sensing/example/lib/main.dart +++ b/carp_mobile_sensing/example/lib/main.dart @@ -91,13 +91,6 @@ class StudyPageState extends State { // measures are available. This must happen before configuring the client. SamplingPackageRegistry().register(ContextSamplingPackage()); - // Request location permission BEFORE configuring the client. CAMS connects - // the LocationService device during deployment/startup, and that connect - // fails (and is never retried) if permission isn't already granted — which - // leaves the location task unable to resume. - await LocationManager().configure(locationService); - await LocationManager().requestPermission(); - // Configure the client. Note that the client can take a series of configuration // parameters, but here we're just using the default configurations. await client.configure(); @@ -229,10 +222,6 @@ class StudyPageState extends State { if (study.isSampling) { controller?.pause(); } else { - // CAMS does not request location permission itself, so ask for it here - // before sampling starts. This is what pops the OS location dialog. - await LocationManager().configure(locationService); - await LocationManager().requestPermission(); controller?.resume(); } setState(() {}); diff --git a/carp_mobile_sensing/lib/domain/core/smartphone_deployment.dart b/carp_mobile_sensing/lib/domain/core/smartphone_deployment.dart index 0a15d60e3..9a631e2c9 100644 --- a/carp_mobile_sensing/lib/domain/core/smartphone_deployment.dart +++ b/carp_mobile_sensing/lib/domain/core/smartphone_deployment.dart @@ -189,6 +189,11 @@ class SmartphoneDeployment extends PrimaryDeviceDeployment return measures; } + /// Does this deployment have a task that notifies the user? + /// Android 13+ needs permission for that. + bool get hasNotifyingTask => + tasks.any((task) => task is AppTask && task.notification); + /// Get the [DeviceConfiguration] based on the [roleName]. /// This includes both the primary device and the connected devices. /// Returns null if no device with [roleName] is found. diff --git a/carp_mobile_sensing/lib/infrastructure/services/local_notification_manager.dart b/carp_mobile_sensing/lib/infrastructure/services/local_notification_manager.dart index 5b7ad9b4d..2daaba4b3 100644 --- a/carp_mobile_sensing/lib/infrastructure/services/local_notification_manager.dart +++ b/carp_mobile_sensing/lib/infrastructure/services/local_notification_manager.dart @@ -30,14 +30,6 @@ class FlutterLocalNotificationManager implements NotificationManager { Future configure() async { tz.initializeTimeZones(); - List permissions = List.from([ - Permission.notification, - Permission.scheduleExactAlarm, - ]); - - var status = await permissions.request(); - debug('$runtimeType - Permissions: $status'); - await FlutterLocalNotificationsPlugin().initialize( settings: const InitializationSettings( android: AndroidInitializationSettings('ic_launcher'), @@ -51,6 +43,20 @@ class FlutterLocalNotificationManager implements NotificationManager { info('$runtimeType configured.'); } + /// How to schedule on Android. + /// + /// Exact alarms need the `SCHEDULE_EXACT_ALARM` permission, which Android + /// only grants through a settings screen and Google Play only allows for + /// alarm-clock-like apps. Without it, scheduling an exact alarm throws. + /// + /// So we use it when it happens to be granted, and otherwise schedule + /// inexactly: still delivered while the phone is idle, just within minutes of + /// the requested time rather than to the second. + Future get _scheduleMode async => + await Permission.scheduleExactAlarm.isGranted + ? AndroidScheduleMode.exactAllowWhileIdle + : AndroidScheduleMode.inexactAllowWhileIdle; + final NotificationDetails _platformChannelSpecifics = const NotificationDetails( android: AndroidNotificationDetails( @@ -101,7 +107,7 @@ class FlutterLocalNotificationManager implements NotificationManager { body: body, scheduledDate: time, notificationDetails: _platformChannelSpecifics, - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + androidScheduleMode: await _scheduleMode, ); return id; @@ -134,7 +140,7 @@ class FlutterLocalNotificationManager implements NotificationManager { body: body, scheduledDate: time, notificationDetails: _platformChannelSpecifics, - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + androidScheduleMode: await _scheduleMode, matchDateTimeComponents: recurrence, ); @@ -178,7 +184,7 @@ class FlutterLocalNotificationManager implements NotificationManager { body: task.description, scheduledDate: time, notificationDetails: _platformChannelSpecifics, - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + androidScheduleMode: await _scheduleMode, payload: task.id, ); task.hasNotificationBeenCreated = true; diff --git a/carp_mobile_sensing/lib/runtime.dart b/carp_mobile_sensing/lib/runtime.dart index b4fd4896f..163369684 100644 --- a/carp_mobile_sensing/lib/runtime.dart +++ b/carp_mobile_sensing/lib/runtime.dart @@ -42,6 +42,7 @@ part 'runtime/util/cron_parser.dart'; part 'runtime/app_task_controller.dart'; part 'runtime/client_manager.dart'; part 'runtime/client_repository.dart'; +part 'runtime/permissions.dart'; part 'runtime/study_controller.dart'; part 'runtime/device_manager/device_controller.dart'; part 'runtime/device_manager/device_manager.dart'; diff --git a/carp_mobile_sensing/lib/runtime/client_manager.dart b/carp_mobile_sensing/lib/runtime/client_manager.dart index 03f425f94..9aa8e9eff 100644 --- a/carp_mobile_sensing/lib/runtime/client_manager.dart +++ b/carp_mobile_sensing/lib/runtime/client_manager.dart @@ -49,15 +49,23 @@ class SmartPhoneClientManager final NotificationManager _notificationManager = FlutterLocalNotificationManager(); - bool _askForPermissions = true; + PermissionRequester? _permissionRequester = requestPermissionsInOrder; final StreamGroup _group = StreamGroup.broadcast(); ClientManagerState _state = ClientManagerState.created; final StreamController _controller = StreamController.broadcast(); final Map _controllers = {}; - /// Will this client manager ask for permission when a new study is deployed? - bool get askForPermissions => _askForPermissions; + /// Ask the user for [permissions], using the [PermissionRequester] this + /// client is configured with. Does nothing if there is none. + /// + /// Serialized: `permission_handler` forbids concurrent requests, and two + /// studies can start at once. + Future requestPermissions(List permissions) async => + _asking = _asking.then((_) async { + await _permissionRequester?.call(permissions); + }); + Future _asking = Future.value(); /// The runtime state of this client manager. ClientManagerState get state => _state; @@ -128,9 +136,10 @@ class SmartPhoneClientManager /// titles and text, you can provide them here. /// Note that background mode is only supported on Android, and will be ignored on iOS. /// - /// If [askForPermissions] is true (default), this client manager will - /// automatically ask for permissions for all sampling packages at once. - /// If you want the app to handle permissions itself, set this to false. + /// The [permissionRequester] asks the user for the permissions a study needs, + /// before its devices are connected. Defaults to [requestPermissionsInOrder], + /// which shows the system dialogs one at a time. Pass your own to show a + /// rationale first, or `null` to never ask and handle permissions in the app. /// /// When this method is called, the client manager will restore the state of /// all previously added studies and resume data sampling in those studies if @@ -148,12 +157,12 @@ class SmartPhoneClientManager bool enableBackgroundMode = true, String? backgroundNotificationTitle, String? backgroundNotificationText, - bool askForPermissions = true, + PermissionRequester? permissionRequester = requestPermissionsInOrder, }) async { // Fast out if already configured if (state.index >= ClientManagerState.configured.index) return; - _askForPermissions = askForPermissions; + _permissionRequester = permissionRequester; // Initialize infrastructure services and the repository. await DeviceInfoService().init(); @@ -191,7 +200,6 @@ class SmartPhoneClientManager } // Configure the notification manager. - // This will ask for permissions if needed. await notificationManager.configure(); // Initialize the app task controller. diff --git a/carp_mobile_sensing/lib/runtime/device_manager/device_manager.dart b/carp_mobile_sensing/lib/runtime/device_manager/device_manager.dart index a17d51219..f5de2b7f0 100644 --- a/carp_mobile_sensing/lib/runtime/device_manager/device_manager.dart +++ b/carp_mobile_sensing/lib/runtime/device_manager/device_manager.dart @@ -163,21 +163,36 @@ abstract class DeviceManager< /// doing a lot of work on startup. void onConfigure(); + /// The permissions this device needs. + /// + /// Declared, not requested - the study controller collects the permissions of + /// every device and measure in a deployment and asks for them once, before + /// connecting anything. See [SmartphoneStudyController.requiredPermissions]. + List get permissions => []; + /// Does this device manager have the [permissions] to run? /// /// Note that the result is not cached, since permissions can be revoked in /// the phone's settings at any time, without the app knowing about it. @nonVirtual - Future hasPermissions() async { - return onHasPermissions(); - } + Future hasPermissions() async => onHasPermissions(); - /// Callback on [hasPermissions]. + /// Callback on [hasPermissions]. Defaults to requiring all [permissions]. /// - /// Can be overridden in sub-classes for device-specific permission handling. - Future onHasPermissions() async => true; + /// Override to require only some of them - e.g. a service that also works + /// without its optional background permission - or for devices whose + /// permissions are not handled by `permission_handler`, such as health data. + Future onHasPermissions() async { + for (final permission in permissions) { + if (!await permission.isGranted) return false; + } + return true; + } /// Request all [permissions] for this device manager. + /// + /// Only needed when connecting a device on demand, e.g. from a settings page. + /// Devices in a deployment have their permissions requested up front. @nonVirtual Future requestPermissions() async { info('$runtimeType - Requesting permissions for device of type: $typeName.'); @@ -185,10 +200,12 @@ abstract class DeviceManager< await onRequestPermissions(); } - /// Callback on [requestPermissions]. + /// Callback on [requestPermissions]. Defaults to asking for [permissions]. /// - /// Can be overridden for device-specific permission handling. - Future onRequestPermissions(); + /// Override for devices whose permissions are not handled by + /// `permission_handler`, such as health data. + Future onRequestPermissions() => + SmartPhoneClientManager().requestPermissions(permissions); /// Ask this [DeviceManager] to start connecting to the device. /// Returns the [DeviceStatus] of the device. diff --git a/carp_mobile_sensing/lib/runtime/device_manager/device_managers.dart b/carp_mobile_sensing/lib/runtime/device_manager/device_managers.dart index 6eef6ec88..486ff3c34 100644 --- a/carp_mobile_sensing/lib/runtime/device_manager/device_managers.dart +++ b/carp_mobile_sensing/lib/runtime/device_manager/device_managers.dart @@ -106,30 +106,21 @@ abstract class BLEDeviceManager< bool onPaired() => true; @override - @mustCallSuper - Future onHasPermissions() async => (Platform.isAndroid) - ? await Permission.bluetoothConnect.isGranted && - await Permission.bluetoothScan.isGranted && - // BLE scanning on Android also requires location permission. - await Permission.locationWhenInUse.isGranted - // : (Platform.isIOS) - // ? await Permission.bluetooth.isGranted - // for some reason it seems like Permission.bluetooth.isGranted always - // return false on iOS....? - : true; - + List get permissions => Platform.isAndroid + ? [ + Permission.bluetoothScan, + Permission.bluetoothConnect, + // BLE scanning on Android also requires location permission. + Permission.locationWhenInUse, + ] + : [Permission.bluetooth]; + + /// `Permission.bluetooth.isGranted` always reports false on iOS, so checking + /// it there tells us nothing. iOS prompts on first BLE use anyway. @override @mustCallSuper - Future onRequestPermissions() async { - if (Platform.isAndroid) { - await Permission.bluetoothScan.request(); - await Permission.bluetoothConnect.request(); - await Permission.locationWhenInUse.request(); - } - if (Platform.isIOS) { - await Permission.bluetooth.request(); - } - } + Future onHasPermissions() async => + Platform.isIOS ? true : super.onHasPermissions(); } /// A device manager for a smartphone. @@ -196,9 +187,6 @@ class SmartphoneDeviceManager @override bool get canConnect => true; // can always connect to the phone - @override - Future onRequestPermissions() async {} - @override Future onConnect() async => DeviceStatus.connected; diff --git a/carp_mobile_sensing/lib/runtime/executors/probes.dart b/carp_mobile_sensing/lib/runtime/executors/probes.dart index 253e7ef07..6ae2692d3 100644 --- a/carp_mobile_sensing/lib/runtime/executors/probes.dart +++ b/carp_mobile_sensing/lib/runtime/executors/probes.dart @@ -10,11 +10,10 @@ part of '../../runtime.dart'; /// A [Probe] is a specialized [Executor] responsible for collecting data from /// the device sensors as configured in a [Measure]. /// -/// A probe may need a set of [permissions] to run. Following best practice on -/// both [Android](https://developer.android.com/training/permissions/requesting) -/// and [iOS](https://developer.apple.com/documentation/uikit/protecting_the_user_s_privacy/requesting_access_to_protected_resources/) -/// a probe will ask for permission when started using the [requestPermissions] -/// method. +/// A probe may need a set of [permissions] to run. It never asks for them +/// itself - they are requested for the deployment as a whole, before sampling +/// starts. A probe without its permissions does not resume, and is resumed +/// later when its device (re)connects. abstract class Probe extends AbstractExecutor { /// The device that this probes uses to collect data. late DeviceManager deviceManager; @@ -66,81 +65,31 @@ abstract class Probe extends AbstractExecutor { super.addMeasurement(measurement); } - List? _permissions; - - /// The list of permissions needed for this probe. + /// The permissions needed for this probe to run, as declared by its + /// [CamsDataTypeMetaData]. List get permissions { - if (_permissions == null) { - var schema = SamplingPackageRegistry().samplingSchemes[type]; - _permissions = (schema != null && schema.dataType is CamsDataTypeMetaData) - ? (schema.dataType as CamsDataTypeMetaData).permissions - : []; - } - return _permissions!; + final dataType = SamplingPackageRegistry().samplingSchemes[type]?.dataType; + return dataType is CamsDataTypeMetaData ? dataType.permissions : const []; } - /// Does this probe has the permissions needed to run? - Future arePermissionsGranted() async { - // fast out if no permissions to check - if (permissions.isEmpty) return true; - - debug('$runtimeType - Checking permission for: $permissions'); - bool granted = true; - - try { - for (var permission in permissions) { - granted = granted && await permission.isGranted; - } - } catch (error) { - addError( - '$runtimeType - Error trying to check permissions, error: $error', - ); - return false; - } - return granted; - } - - /// Request the permissions needed for this probe to run. - /// Return true if all permissions are granted. - /// Only used on Android - iOS automatically request permissions when - /// a resource (like the microphone) is accessed. - Future requestPermissions() async { - // fast out if on iOS - permissions are automatically requested + /// Whether this probe is allowed to run. + /// + /// Probes only check. The permissions of a whole deployment are requested up + /// front, before its devices connect - see + /// [SmartphoneStudyController.requiredPermissions]. On iOS there is nothing + /// to check: permissions are requested when a resource is first accessed. + Future hasRequiredPermissions() async { if (Platform.isIOS) return true; - // fast out if already have permissions - if (await arePermissionsGranted()) return true; - - debug('$runtimeType - Asking permission for: $permissions'); - bool granted = true; - - try { - final status = await permissions.request(); - debug('$runtimeType - Permission status: $status'); - - granted = status.values.fold( - true, - (value, status) => value && status == PermissionStatus.granted, - ); - } catch (error) { - addError( - '$runtimeType - Error trying to request permissions, error: $error', - ); - return false; + for (final permission in permissions) { + if (!await permission.isGranted) { + warning('$runtimeType - Missing permission: $permission'); + return false; + } } - return granted; + return true; } - /// Whether this probe is allowed to run. - /// - /// On Android the [SmartphoneStudyController] requests all deployment - /// permissions in a single batch ([SmartphoneStudyController.askForAllPermissions]), - /// so probes only check - requesting again here would collide, as - /// permission_handler forbids concurrent requests. On iOS permissions are - /// requested automatically when a resource is accessed. - Future hasRequiredPermissions() async => - Platform.isIOS ? true : await arePermissionsGranted(); - // default no-op implementation of callback methods below @override diff --git a/carp_mobile_sensing/lib/runtime/permissions.dart b/carp_mobile_sensing/lib/runtime/permissions.dart new file mode 100644 index 000000000..f080950b0 --- /dev/null +++ b/carp_mobile_sensing/lib/runtime/permissions.dart @@ -0,0 +1,45 @@ +/* + * Copyright 2026 the Technical University of Denmark (DTU). + * Use of this source code is governed by a MIT-style license that can be + * found in the LICENSE file. + */ + +part of '../runtime.dart'; + +/// Asks the user for [permissions] and completes once done. +/// +/// Used to let an app take over how permissions are requested - e.g., to show +/// a rationale before each system dialog, or to defer them to an onboarding +/// flow. See [SmartPhoneClientManager.configure]. +/// +/// Whatever the user answers, CAMS re-checks the actual permission status +/// afterwards, so a requester never needs to report back. +typedef PermissionRequester = + Future Function(List permissions); + +/// Requests [permissions] one at a time, skipping those already granted. +/// +/// This is the default [PermissionRequester]. +/// +/// One at a time is not a style choice: `permission_handler` forbids concurrent +/// requests, and a permission that unlocks another one can only be granted if +/// asked first. +Future requestPermissionsInOrder(List permissions) async { + final asked = {}; + + for (final permission in permissions.expand(_withPrerequisite)) { + if (!asked.add(permission)) continue; + if (await permission.isGranted) continue; + + final status = await permission.request(); + info('Permission ${permission.toString().split('.').last}: ${status.name}'); + } +} + +/// Android only offers `locationAlways` once `locationWhenInUse` is granted, +/// and only in a separate dialog. Climbing that ladder here means a study can +/// declare the permissions it needs in any order and still get asked correctly. +Iterable _withPrerequisite(Permission permission) => + permission == Permission.locationAlways + ? [Permission.locationWhenInUse, permission] + : [permission]; diff --git a/carp_mobile_sensing/lib/runtime/study_controller.dart b/carp_mobile_sensing/lib/runtime/study_controller.dart index 2d05f8e2d..2225d59cc 100644 --- a/carp_mobile_sensing/lib/runtime/study_controller.dart +++ b/carp_mobile_sensing/lib/runtime/study_controller.dart @@ -20,7 +20,6 @@ class SmartphoneStudyController { final SmartphoneStudy _study; DataManager? _dataManager; final SmartphoneDeploymentExecutor _executor = SmartphoneDeploymentExecutor(); - Map? _permissions; /// Create a new [SmartphoneStudyController] to control the runtime behavior /// of a [study]. @@ -76,9 +75,6 @@ class SmartphoneStudyController { DeploymentService get _deploymentService => SmartPhoneClientManager().deploymentService; - /// The permissions granted to this client from the OS. - Map get permissions => _permissions ?? {}; - /// The executor executing the [deployment]. SmartphoneDeploymentExecutor get executor => _executor; @@ -181,6 +177,11 @@ class SmartphoneStudyController { _executor.initialize(deployment!, deployment!); _executor.setSamplingState(existingSamplingStatus); + // Ask for the permissions this deployment needs. Must happen before + // connecting, since a device that lacks its permissions refuses to connect + // and nothing reconnects it afterwards. + await SmartPhoneClientManager().requestPermissions(requiredPermissions); + // Connect to all connectable devices. // (Re-)connecting a device will trigger that // - the device is re-registered with the deployment service, @@ -346,73 +347,46 @@ class SmartphoneStudyController { ); } - /// Asking for permissions for all the measures included in this - /// [study]. + /// The permissions needed to start sampling this [deployment] now - those of + /// every measure, and of the devices about to be connected. /// - /// Since we only ask for permission relevant to the deployment, this method - /// should be called after deployment has taken place but before this controller - /// is resumed. + /// Devices that are not ready to connect are left out: a wearable the + /// participant has not paired yet is not connected at study start, so asking + /// for its permissions now would be asking for something we are not about to + /// use. An app that lets the participant pair such a device later asks then, + /// via [DeviceManager.requestPermissions]. /// - /// This method is only relevant on Android, and does nothing on iOS. - /// iOS automatically asks for permissions when a resource is accessed. + /// Available once the deployment is received, so an app can show what a study + /// needs (and why) before any system dialog appears. + List get requiredPermissions => [ + if (deployment?.hasNotifyingTask ?? false) Permission.notification, + for (final measure in deployment?.measures ?? []) + ...?_measurePermissions(measure), + for (final device in _devicesToConnect) ...device.permissions, + ]; + + /// The devices in this [deployment] that are ready to be connected right now. /// - /// Note that location permissions are never asked for in this method, since - /// they can cause issues when asking for multiple permissions at once. - /// Location permissions should be handled separately in the app. - Future askForAllPermissions() async { - if (deployment == null) { - warning( - '$runtimeType - No deployment available. Skipping requesting permissions.', - ); - return; - } - if (Platform.isIOS) { - warning( - '$runtimeType - Requesting all permissions at once is not feasible on iOS. Skipping this.', + /// A device is not ready when it still lacks what it needs to connect - a BLE + /// wearable that has not been paired yet, say - or when the participant has + /// unregistered it. + Iterable get _devicesToConnect sync* { + for (final configuration in deployment?.devices ?? []) { + final device = _deviceController.getDeviceManager(configuration.type); + debug( + '$runtimeType - Checking to connect to device $device with canConnect ' + "'${device?.canConnect}' and shouldConnect '${device?.shouldConnect}'...", ); - return; - } - - Set permissions = {}; - - for (var measure in deployment?.measures ?? []) { - var schema = SamplingPackageRegistry().samplingSchemes[measure.type]; - if (schema != null && schema.dataType is CamsDataTypeMetaData) { - permissions.addAll( - (schema.dataType as CamsDataTypeMetaData).permissions, - ); + if (device != null && device.canConnect && device.shouldConnect) { + yield device; } } + } - debug( - '$runtimeType - Required permissions for this deployment: $permissions', - ); - - if (permissions.isNotEmpty) { - // Never ask for location permissions. - // Will mess it up when requesting multiple permissions at once. - permissions - ..remove(Permission.location) - ..remove(Permission.locationWhenInUse) - ..remove(Permission.locationAlways); - - try { - info( - '$runtimeType - Asking for permissions for all measures in this deployment...', - ); - _permissions = await permissions.toList().request(); - - debug('$runtimeType - Permissions granted: $_permissions'); - - _permissions?.forEach( - (permission, status) => info( - '$runtimeType - Permission status for ${permission.toString().split('.').last} : ${status.name}', - ), - ); - } catch (error) { - warning('$runtimeType - Error requesting permissions - error: $error'); - } - } + List? _measurePermissions(Measure measure) { + final dataType = + SamplingPackageRegistry().samplingSchemes[measure.type]?.dataType; + return dataType is CamsDataTypeMetaData ? dataType.permissions : null; } /// Configure all devices in this [deployment]. @@ -480,15 +454,8 @@ class SmartphoneStudyController { debug('$runtimeType - Trying to connect to all connectable devices.'); // connect all the connected devices and the primary device (i.e. this phone) - for (var configuration in deployment?.devices ?? []) { - var device = _deviceController.getDeviceManager(configuration.type); - debug( - '$runtimeType - Checking to connect to device $device with canConnect ' - "'${device?.canConnect}' and shouldConnect '${device?.shouldConnect}'...", - ); - if (device != null && device.canConnect && device.shouldConnect) { - await device.connect(); - } + for (final device in _devicesToConnect) { + await device.connect(); } } @@ -517,11 +484,6 @@ class SmartphoneStudyController { ); } - // Ask for permissions for all measures in this deployment - if (SmartPhoneClientManager().askForPermissions) { - await askForAllPermissions(); - } - // Finally, resume/pause data sampling based on the current sampling state of this study. if (study.samplingState?.state == ExecutorState.Resumed) { debug('$runtimeType - Restarting sampling in 15 seconds...'); diff --git a/carp_mobile_sensing/pubspec.yaml b/carp_mobile_sensing/pubspec.yaml index b704c8d1a..7d2d1a9e7 100644 --- a/carp_mobile_sensing/pubspec.yaml +++ b/carp_mobile_sensing/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_mobile_sensing -version: 2.3.1 +version: 3.0.0 description: Mobile Sensing Framework for Flutter. A software framework for collecting sensor data from the phone and attached wearable devices via probes. Can be extended. # Note that the following URLs to GitHub should use the old 'cph-cachet' name. @@ -74,4 +74,6 @@ dev_dependencies: test: any fake_async: any flutter_lints: any + flutter_test: + sdk: flutter sqflite_common_ffi: any diff --git a/carp_mobile_sensing/test/permissions_test.dart b/carp_mobile_sensing/test/permissions_test.dart new file mode 100644 index 000000000..923246cdb --- /dev/null +++ b/carp_mobile_sensing/test/permissions_test.dart @@ -0,0 +1,180 @@ +import 'package:carp_core/carp_core.dart' hide Smartphone; +import 'package:carp_mobile_sensing/carp_mobile_sensing.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:permission_handler/permission_handler.dart'; + +const _channel = MethodChannel('flutter.baseflow.com/permissions/methods'); + +// The values permission_handler sends over its method channel. +const _denied = 0, _granted = 1; + +/// A device needing location, which Android grants in two steps. +class _LocationishDeviceManager + extends DeviceManager { + _LocationishDeviceManager() : super('test.LocationishDevice'); + + @override + List get permissions => [ + Permission.locationWhenInUse, + Permission.locationAlways, + ]; + + @override + void onConfigure() {} + @override + bool get canConnect => true; + @override + Future onConnect() async => DeviceStatus.connected; + @override + Future onDisconnect() async => true; + @override + DeviceRegistration createRegistration() => DeviceRegistration(); + String get id => 'test'; + @override + String? get displayName => 'Test device'; +} + +/// A deployment of [protocol], ready to be asked what permissions it needs. +SmartphoneDeployment deploymentOf(SmartphoneStudyProtocol protocol) => + SmartphoneDeployment.fromSmartphoneStudyProtocol( + studyDeploymentId: 'test', + primaryDeviceRoleName: protocol.primaryDevice.roleName, + protocol: protocol, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + CarpMobileSensing.ensureInitialized(); + + /// Fake the OS: records the calls, grants whatever is asked for. + List fakePermissionHandler({Set alreadyGranted = const {}}) { + final calls = []; + final granted = {...alreadyGranted}; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + switch (call.method) { + case 'checkPermissionStatus': + final permission = call.arguments as int; + calls.add('check($permission)'); + return granted.contains(permission) ? _granted : _denied; + case 'requestPermissions': + final requested = (call.arguments as List).cast(); + calls.add('request(${requested.join(',')})'); + granted.addAll(requested); + return {for (final p in requested) p: _granted}; + default: + return null; + } + }); + + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null), + ); + return calls; + } + + test('permissions are requested one at a time, in declared order', () async { + final calls = fakePermissionHandler(); + + await requestPermissionsInOrder(_LocationishDeviceManager().permissions); + + // One request per permission, in order. Batching them into a single + // request() would break Android's location ladder: locationAlways is only + // offered once locationWhenInUse is granted. + expect(calls.where((call) => call.startsWith('request')), [ + 'request(${Permission.locationWhenInUse.value})', + 'request(${Permission.locationAlways.value})', + ]); + }); + + test('locationAlways always climbs the ladder, however declared', () async { + // A sampling package really does declare it this way, without the + // 'when in use' step Android requires first. + final calls = fakePermissionHandler(); + + await requestPermissionsInOrder([ + Permission.bluetoothScan, + Permission.locationAlways, + ]); + + expect(calls.where((call) => call.startsWith('request')), [ + 'request(${Permission.bluetoothScan.value})', + 'request(${Permission.locationWhenInUse.value})', + 'request(${Permission.locationAlways.value})', + ]); + }); + + test('a permission listed twice is only asked for once', () async { + final calls = fakePermissionHandler(); + + await requestPermissionsInOrder([ + Permission.locationAlways, + Permission.locationWhenInUse, // already covered by the ladder above + ]); + + expect(calls.where((call) => call.startsWith('request')), [ + 'request(${Permission.locationWhenInUse.value})', + 'request(${Permission.locationAlways.value})', + ]); + }); + + test('already granted permissions are not asked for again', () async { + final calls = fakePermissionHandler( + alreadyGranted: {Permission.locationWhenInUse.value}, + ); + + await requestPermissionsInOrder(_LocationishDeviceManager().permissions); + + expect(calls.where((call) => call.startsWith('request')), [ + 'request(${Permission.locationAlways.value})', + ]); + }); + + test('a study with a notifying task needs notification permission', () { + final protocol = SmartphoneStudyProtocol( + ownerId: 'test', + name: 'Notifying protocol', + )..addPrimaryDevice(Smartphone()); + + expect(deploymentOf(protocol).hasNotifyingTask, isFalse); + + protocol.addTaskControl( + ImmediateTrigger(), + AppTask(type: 'test', notification: true), + protocol.primaryDevice, + Control.Start, + ); + + // Android 13+ needs Permission.notification, and a task that notifies is + // the only reason to ask for it. + expect(deploymentOf(protocol).hasNotifyingTask, isTrue); + }); + + test('a device without its permissions refuses to connect', () async { + fakePermissionHandler(); // nothing granted, nothing requested + final device = _LocationishDeviceManager() + ..configure(DeviceConfiguration(roleName: 'test')); + + // This is why permissions must be requested *before* connecting: a device + // that connects first sees no permissions, gives up, and nothing retries. + expect(await device.hasPermissions(), isFalse); + expect(await device.connect(), DeviceStatus.disconnected); + }); + + test('a device with its permissions connects', () async { + fakePermissionHandler( + alreadyGranted: { + Permission.locationWhenInUse.value, + Permission.locationAlways.value, + }, + ); + final device = _LocationishDeviceManager() + ..configure(DeviceConfiguration(roleName: 'test')); + + expect(await device.hasPermissions(), isTrue); + expect(await device.connect(), DeviceStatus.connected); + }); +} diff --git a/packages/carp_apps_package/CHANGELOG.md b/packages/carp_apps_package/CHANGELOG.md index 5b4a72f65..6d4a68400 100644 --- a/packages/carp_apps_package/CHANGELOG.md +++ b/packages/carp_apps_package/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0 + +* require `carp_mobile_sensing` ^3.0.0 + ## 2.1.0 * require `carp_serializable` ^3.0.0, which replaces the built-in `Uuid` with the [uuid](https://pub.dev/packages/uuid) package diff --git a/packages/carp_apps_package/pubspec.yaml b/packages/carp_apps_package/pubspec.yaml index 02ca3a714..2c7a942c5 100644 --- a/packages/carp_apps_package/pubspec.yaml +++ b/packages/carp_apps_package/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_apps_package -version: 2.1.0 +version: 3.0.0 description: Apps sampling package for the CARP Mobile Sensing framework (Android only). homepage: https://github.com/cph-cachet/carp.sensing-flutter repository: https://github.com/cph-cachet/carp.sensing-flutter/tree/main/packages/carp_apps_package @@ -26,7 +26,7 @@ dependencies: carp_serializable: ^3.0.0 carp_core: ^2.2.0 - carp_mobile_sensing: ^2.2.0 + carp_mobile_sensing: ^3.0.0 app_usage: ^4.0.0 installed_apps: ^2.0.0 diff --git a/packages/carp_audio_package/CHANGELOG.md b/packages/carp_audio_package/CHANGELOG.md index 43daeb7c4..5d4aab089 100644 --- a/packages/carp_audio_package/CHANGELOG.md +++ b/packages/carp_audio_package/CHANGELOG.md @@ -1,3 +1,9 @@ +## 3.0.0 + +* require `carp_mobile_sensing` ^3.0.0 +* the audio probe checks its permission instead of requesting it mid-recording - microphone + access is requested with the rest of the study's permissions, before sampling starts + ## 2.1.0 * require `carp_serializable` ^3.0.0, which replaces the built-in `Uuid` with the [uuid](https://pub.dev/packages/uuid) package diff --git a/packages/carp_audio_package/lib/audio_probe.dart b/packages/carp_audio_package/lib/audio_probe.dart index 02bba0e35..7ede71553 100644 --- a/packages/carp_audio_package/lib/audio_probe.dart +++ b/packages/carp_audio_package/lib/audio_probe.dart @@ -30,21 +30,19 @@ class AudioProbe extends Probe { @override Future onResume() async { - if (await requestPermissions()) { - try { - await _startAudioRecording(); - debug( - '$runtimeType [$hashCode] - Audio recording started - sound file : $_soundFileName', - ); - } catch (error) { - warning('An error occurred trying to start audio recording - $error'); - addError(error); - return false; - } - return true; - } else { + if (!await hasRequiredPermissions()) return false; + + try { + await _startAudioRecording(); + debug( + '$runtimeType [$hashCode] - Audio recording started - sound file : $_soundFileName', + ); + } catch (error) { + warning('An error occurred trying to start audio recording - $error'); + addError(error); return false; } + return true; } @override diff --git a/packages/carp_audio_package/pubspec.yaml b/packages/carp_audio_package/pubspec.yaml index 0d9197e0a..e3e5acc7f 100644 --- a/packages/carp_audio_package/pubspec.yaml +++ b/packages/carp_audio_package/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_audio_package -version: 2.1.0 +version: 3.0.0 description: CARP Media Sampling Package. Samples audio, video, image, and noise. homepage: https://github.com/cph-cachet/carp.sensing-flutter @@ -27,7 +27,7 @@ dependencies: carp_serializable: ^3.0.0 carp_core: ^2.2.0 - carp_mobile_sensing: ^2.2.0 + carp_mobile_sensing: ^3.0.0 json_annotation: ^4.8.0 permission_handler: '>=11.0.0 <13.0.0' diff --git a/packages/carp_communication_package/CHANGELOG.md b/packages/carp_communication_package/CHANGELOG.md index 44e643c67..dbba5af1e 100644 --- a/packages/carp_communication_package/CHANGELOG.md +++ b/packages/carp_communication_package/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0 + +* require `carp_mobile_sensing` ^3.0.0 + ## 2.1.0 * require `carp_serializable` ^3.0.0, which replaces the built-in `Uuid` with the [uuid](https://pub.dev/packages/uuid) package diff --git a/packages/carp_communication_package/pubspec.yaml b/packages/carp_communication_package/pubspec.yaml index 54f05ada9..f0864a78a 100644 --- a/packages/carp_communication_package/pubspec.yaml +++ b/packages/carp_communication_package/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_communication_package -version: 2.1.0 +version: 3.0.0 description: CARP communication sampling package. Samples phone, sms, and calendar logs and activity. homepage: https://github.com/cph-cachet/carp.sensing-flutter repository: https://github.com/cph-cachet/carp.sensing-flutter/tree/main/packages/carp_communication_package @@ -26,7 +26,7 @@ dependencies: carp_serializable: ^3.0.0 carp_core: ^2.2.0 - carp_mobile_sensing: ^2.2.0 + carp_mobile_sensing: ^3.0.0 json_annotation: ^4.8.0 another_telephony: ^0.4.0 diff --git a/packages/carp_connectivity_package/CHANGELOG.md b/packages/carp_connectivity_package/CHANGELOG.md index 9af54de81..41ede2426 100644 --- a/packages/carp_connectivity_package/CHANGELOG.md +++ b/packages/carp_connectivity_package/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0 + +* require `carp_mobile_sensing` ^3.0.0 + ## 2.2.0 * upgrade to `network_info_plus` ^8.2.1, which pulls in `win32` 6.x diff --git a/packages/carp_connectivity_package/pubspec.yaml b/packages/carp_connectivity_package/pubspec.yaml index dcc96d306..a628cecdc 100644 --- a/packages/carp_connectivity_package/pubspec.yaml +++ b/packages/carp_connectivity_package/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_connectivity_package -version: 2.2.0 +version: 3.0.0 description: CARP connectivity sampling package. Samples connectivity status, bluetooth devices, and wifi access points. homepage: https://github.com/cph-cachet/carp.sensing-flutter repository: https://github.com/cph-cachet/carp.sensing-flutter/tree/main/packages/carp_connectivity_package @@ -26,7 +26,7 @@ dependencies: carp_serializable: ^3.0.0 carp_core: ^2.2.0 - carp_mobile_sensing: ^2.3.0 + carp_mobile_sensing: ^3.0.0 json_annotation: ^4.8.0 connectivity_plus: ^7.0.0 # connectivity events diff --git a/packages/carp_context_package/CHANGELOG.md b/packages/carp_context_package/CHANGELOG.md index c1d8afd1c..750b683d5 100644 --- a/packages/carp_context_package/CHANGELOG.md +++ b/packages/carp_context_package/CHANGELOG.md @@ -1,3 +1,15 @@ +## 3.0.0 + +* require `carp_mobile_sensing` ^3.0.0 +* location permissions are declared, not requested: the context services declare + `Permission.locationAlways` and CAMS asks for it - `locationWhenInUse` first - before + connecting them. Removes the three separate ways location used to be requested +* `LocationManager.requestPermission()` removed. Location is requested like any other + permission now; use `SmartPhoneClientManager().requestPermissions(...)` if you need to ask +* `LocationManager.configure()` no longer requests permission as a side effect - it bails out + when permission is missing, and configures when the service is connected again +* a location probe denied permission now stays paused instead of silently reporting success + ## 2.1.0 * require `carp_serializable` ^3.0.0, which replaces the built-in `Uuid` with the [uuid](https://pub.dev/packages/uuid) package diff --git a/packages/carp_context_package/lib/src/context_package.dart b/packages/carp_context_package/lib/src/context_package.dart index 6eab57196..828296b34 100644 --- a/packages/carp_context_package/lib/src/context_package.dart +++ b/packages/carp_context_package/lib/src/context_package.dart @@ -251,13 +251,17 @@ abstract class ContextServiceManager< isConnected: isConnected, ); + /// All context services need to know where the phone is - weather and air + /// quality look up the location before querying their web service. + /// + /// Background location, so these keep working when the app is not in use. @override - Future onHasPermissions() async => - await LocationManager().hasPermission(); + List get permissions => [Permission.locationAlways]; + /// Background location is a bonus, not a requirement: without it these + /// services still work while the app is in use. @override - Future onRequestPermissions() async => - await LocationManager().requestPermission(); + Future onHasPermissions() => Permission.locationWhenInUse.isGranted; @override bool get canConnect => true; // most online services can always connect - override if not... diff --git a/packages/carp_context_package/lib/src/location/location_probes.dart b/packages/carp_context_package/lib/src/location/location_probes.dart index 75ec93ce7..8dbd79995 100644 --- a/packages/carp_context_package/lib/src/location/location_probes.dart +++ b/packages/carp_context_package/lib/src/location/location_probes.dart @@ -46,28 +46,28 @@ class ConfigurableLocationProbe extends Probe { @override Future onResume() async { - if (await requestPermissions()) { - // if this is a one-time sampling, just get the location once and return - if (oneTimeSampling) { - try { - final location = await deviceManager.manager.getLocation(); - addMeasurement(Measurement.fromData(location)); - } catch (error) { - warning('$runtimeType - Error getting location - $error'); - addError('$runtimeType - Error getting location: $error'); - } - // automatically pause this probe after it is done collecting the measurement - Future.delayed(const Duration(seconds: 5), () => pause()); - } else { - var stream = deviceManager.manager.onLocationChanged.map( - (location) => Measurement.fromData(location), - ); + if (!await hasRequiredPermissions()) return false; - _subscription = stream.listen( - (measurement) => addMeasurement(measurement), - onError: (Object error) => addError(error), - ); + // if this is a one-time sampling, just get the location once and return + if (oneTimeSampling) { + try { + final location = await deviceManager.manager.getLocation(); + addMeasurement(Measurement.fromData(location)); + } catch (error) { + warning('$runtimeType - Error getting location - $error'); + addError('$runtimeType - Error getting location: $error'); } + // automatically pause this probe after it is done collecting the measurement + Future.delayed(const Duration(seconds: 5), () => pause()); + } else { + var stream = deviceManager.manager.onLocationChanged.map( + (location) => Measurement.fromData(location), + ); + + _subscription = stream.listen( + (measurement) => addMeasurement(measurement), + onError: (Object error) => addError(error), + ); } return true; } diff --git a/packages/carp_context_package/lib/src/location/location_services.dart b/packages/carp_context_package/lib/src/location/location_services.dart index 4a3c19cfe..76e0b5698 100644 --- a/packages/carp_context_package/lib/src/location/location_services.dart +++ b/packages/carp_context_package/lib/src/location/location_services.dart @@ -93,11 +93,4 @@ class LocationServiceManager extends ContextServiceManager { ? DeviceStatus.connected : DeviceStatus.disconnected; } - - @override - Future onHasPermissions() async => await manager.hasPermission(); - - @override - Future onRequestPermissions() async => - await manager.requestPermission(); } diff --git a/packages/carp_context_package/lib/src/location_manager.dart b/packages/carp_context_package/lib/src/location_manager.dart index cd39d3b49..4c1ac7c64 100644 --- a/packages/carp_context_package/lib/src/location_manager.dart +++ b/packages/carp_context_package/lib/src/location_manager.dart @@ -44,13 +44,8 @@ enum GeolocationAccuracy { /// /// `LocationManager()...` /// -/// Note that this [LocationManager] **tries** to handle location permissions -/// during its configuration (via the [configure] method) and the [hasPermission] -/// and [requestPermission] methods. -/// -/// **However**, it is much better - and also recommended by both Apple and -/// Google - to handle permissions on an application level and show the location -/// permission dialogue to the user **before** using probes that depend on location. +/// Does not ask for location permission - CAMS asks for a study's permissions +/// before it connects anything. [configure] only checks, via [hasPermission]. /// /// This [LocationManager] based on the [location](https://pub.dev/packages/location) /// plugin. @@ -77,46 +72,7 @@ class LocationManager { Future isBackgroundModeEnabled() async => await _provider.isBackgroundModeEnabled(); /// Does this location manger have permission to access location? - Future hasPermission() async => (await _provider.hasPermission()) == location.PermissionStatus.granted; - - /// Request permissions to access location. - /// - /// Requesting access to location is a two step process on both Android and iOS: - /// - /// 1. First, ask for using location 'when in use' - /// 2. Then, ask for using location 'always' - /// - /// See the [FAQ in the permission_handler](https://pub.dev/packages/permission_handler#requesting-permissionlocationalways-always-returns-denied-on-android-10-api-29-what-can-i-do) - /// plugin or the [Android](https://developer.android.com/develop/sensors-and-location/location/permissions#request-only-foreground) - /// or [iOS](https://developer.apple.com/documentation/corelocation/requesting-authorization-to-use-location-services) - /// documentation. - /// - /// Note that if the permission is [PermissionStatus.permanentlyDenied], no dialog will be - /// shown on [requestPermission]. In this case, the Settings page from the - /// OS needs to be shown and the user needs to manually allow access to location. - /// The permission_handler plugin has a method named `openAppSettings()` which - /// opens the Settings page on Android / iOS. - /// This method is, however, **NOT** used by this context sampling package, since - /// handling of permissions should be taken care of on an app level. - Future requestPermission() async { - debug('$runtimeType - Requesting permission to access location...'); - - var permissionGranted = await _provider.hasPermission(); - if (permissionGranted == location.PermissionStatus.denied) { - permissionGranted = await _provider.requestPermission(); - if (permissionGranted != location.PermissionStatus.granted) { - warning( - "$runtimeType - The user opted not to allow collection of location data. " - "The only way to change the permission's status now is to let the " - "user manually enables it in the system settings.", - ); - } - } - - debug('$runtimeType - Permission: $permissionGranted'); - - return permissionGranted == location.PermissionStatus.granted ? PermissionStatus.granted : PermissionStatus.denied; - } + Future hasPermission() => Permission.locationWhenInUse.isGranted; /// Enable the [LocationManager] for accessing location also when the app is /// in the background. @@ -176,24 +132,15 @@ class LocationManager { // Only on Android, configure the notification shown when running in background. if (Platform.isAndroid) { - // Need to check if location permission has been granted before trying to - // change settings using the "changeSettings()" methods. - // The location plugin will throw a native Android exception trying to change - // setting without permissions to access location. And this exception is not - // propagated to Flutter and is hence not caught by the try-catch block below. + // The location plugin throws a *native* Android exception when settings are + // changed without location permission - it never reaches Flutter, so it + // cannot be caught below. Bail out instead; connecting the location service + // again once permission is granted will configure it. // // See https://github.com/Lyokone/flutterlocation/blob/c14f8173caf33f8c38d01b28c94e0804c63e0db9/packages/location/android/src/main/java/com/lyokone/location/FlutterLocation.java#L201 - var permission = await Permission.location.status; - if (permission != PermissionStatus.granted) { - warning( - "$runtimeType - Permission to collect location data has not been granted. " - "Cannot configure $runtimeType. " - "Make sure to grant this BEFORE sensing is resumed. " - "The context sampling package does not handle location permissions. This should be handled on the application level.", - ); - - // If not granted, try to request 'when in use' permission. - await Permission.locationWhenInUse.request(); + if (!await hasPermission()) { + warning('$runtimeType - Cannot configure without permission to access location.'); + return; } // Change notification options - only on Android. diff --git a/packages/carp_context_package/pubspec.yaml b/packages/carp_context_package/pubspec.yaml index d4321dec4..1a91044b9 100644 --- a/packages/carp_context_package/pubspec.yaml +++ b/packages/carp_context_package/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_context_package -version: 2.1.0 +version: 3.0.0 description: CARP context sampling package. Samples location, mobility, activity, weather, air-quality, and geofence. homepage: https://github.com/cph-cachet/carp.sensing-flutter repository: https://github.com/cph-cachet/carp.sensing-flutter/tree/main/packages/carp_context_package @@ -26,7 +26,7 @@ dependencies: carp_serializable: ^3.0.0 carp_core: ^2.2.0 - carp_mobile_sensing: ^2.2.0 + carp_mobile_sensing: ^3.0.0 json_annotation: ^4.8.0 permission_handler: '>=11.0.0 <13.0.0' diff --git a/packages/carp_esense_package/CHANGELOG.md b/packages/carp_esense_package/CHANGELOG.md index 32c918cfc..fd3136ac7 100644 --- a/packages/carp_esense_package/CHANGELOG.md +++ b/packages/carp_esense_package/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0 + +* require `carp_mobile_sensing` ^3.0.0 + ## 2.1.0 * require `carp_serializable` ^3.0.0, which replaces the built-in `Uuid` with the [uuid](https://pub.dev/packages/uuid) package diff --git a/packages/carp_esense_package/pubspec.yaml b/packages/carp_esense_package/pubspec.yaml index 3f65adcee..5cc5f6974 100644 --- a/packages/carp_esense_package/pubspec.yaml +++ b/packages/carp_esense_package/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_esense_package -version: 2.1.0 +version: 3.0.0 description: The CARP eSense sampling package. Samples sensor and device events from the eSense ear plug device. homepage: https://github.com/cph-cachet/carp.sensing-flutter repository: https://github.com/cph-cachet/carp.sensing-flutter/tree/main/packages/carp_esense_package @@ -26,7 +26,7 @@ dependencies: carp_serializable: ^3.0.0 carp_core: ^2.2.0 - carp_mobile_sensing: ^2.2.0 + carp_mobile_sensing: ^3.0.0 json_annotation: ^4.8.0 esense_flutter: ^1.0.0 diff --git a/packages/carp_health_package/CHANGELOG.md b/packages/carp_health_package/CHANGELOG.md index 060d301bb..60d19ea0e 100644 --- a/packages/carp_health_package/CHANGELOG.md +++ b/packages/carp_health_package/CHANGELOG.md @@ -1,3 +1,9 @@ +## 5.0.0 + +* require `carp_mobile_sensing` ^3.0.0 +* `HealthServiceManager` keeps overriding `onRequestPermissions()`, since Health Connect and + Apple Health grant access through their own API rather than `permission_handler` + ## 4.1.0 * require `carp_serializable` ^3.0.0, which replaces the built-in `Uuid` with the [uuid](https://pub.dev/packages/uuid) package diff --git a/packages/carp_health_package/lib/health_probe.dart b/packages/carp_health_package/lib/health_probe.dart index d856642f5..96dba71be 100755 --- a/packages/carp_health_package/lib/health_probe.dart +++ b/packages/carp_health_package/lib/health_probe.dart @@ -74,7 +74,6 @@ class HealthProbe extends Probe { /// anymore. In this case, this method cannot be used to request permissions. /// Instead, the user must manually go to the settings of the phone and enable /// the permissions. - @override Future requestPermissions() async { bool permission = await hasPermissions(); if (!permission) { diff --git a/packages/carp_health_package/lib/health_service_manager.dart b/packages/carp_health_package/lib/health_service_manager.dart index 8498602bf..18746db58 100644 --- a/packages/carp_health_package/lib/health_service_manager.dart +++ b/packages/carp_health_package/lib/health_service_manager.dart @@ -165,6 +165,8 @@ class HealthServiceManager return hasHealthPermissions(types); } + /// Health permissions are granted by Health Connect / Apple Health, not by + /// the OS permission system, so this asks the health service directly. @override Future onRequestPermissions() async { await requestHealthPermissions(types); diff --git a/packages/carp_health_package/pubspec.yaml b/packages/carp_health_package/pubspec.yaml index bf177ca8c..1b01c8e2b 100755 --- a/packages/carp_health_package/pubspec.yaml +++ b/packages/carp_health_package/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_health_package -version: 4.1.0 +version: 5.0.0 description: CARP health sampling package. Samples health data from Apple Health or Google Fit. homepage: https://github.com/cph-cachet/carp.sensing-flutter repository: https://github.com/cph-cachet/carp.sensing-flutter/tree/main/packages/carp_health_package @@ -26,7 +26,7 @@ dependencies: carp_serializable: ^3.0.0 carp_core: ^2.2.0 - carp_mobile_sensing: ^2.3.0 + carp_mobile_sensing: ^3.0.0 json_annotation: ^4.8.0 health: ^13.3.2 diff --git a/packages/carp_movesense_package/CHANGELOG.md b/packages/carp_movesense_package/CHANGELOG.md index dde60ae03..998387ceb 100644 --- a/packages/carp_movesense_package/CHANGELOG.md +++ b/packages/carp_movesense_package/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.0.0 + +* require `carp_mobile_sensing` ^3.0.0 + ## 3.1.0 * require `carp_serializable` ^3.0.0, which replaces the built-in `Uuid` with the [uuid](https://pub.dev/packages/uuid) package diff --git a/packages/carp_movesense_package/pubspec.yaml b/packages/carp_movesense_package/pubspec.yaml index 287da9377..3464bd393 100644 --- a/packages/carp_movesense_package/pubspec.yaml +++ b/packages/carp_movesense_package/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_movesense_package -version: 3.1.0 +version: 4.0.0 description: The CARP Movesense sampling package. Samples sensor data from the Movesense MD and ACTIVE (HR+, HR2) devices. homepage: https://github.com/cph-cachet/carp.sensing-flutter repository: https://github.com/cph-cachet/carp.sensing-flutter/tree/main/packages/carp_movesense_package @@ -27,7 +27,7 @@ dependencies: carp_serializable: ^3.0.0 # polymorphic json serialization carp_core: ^2.2.0 # the core CARP domain model - carp_mobile_sensing: ^2.2.0 + carp_mobile_sensing: ^3.0.0 carp_movesense_flutter: ^0.1.0 json_annotation: ^4.8.0 diff --git a/packages/carp_movisens_package/CHANGELOG.md b/packages/carp_movisens_package/CHANGELOG.md index d3a8acf44..cc66b3a3f 100644 --- a/packages/carp_movisens_package/CHANGELOG.md +++ b/packages/carp_movisens_package/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0 + +* require `carp_mobile_sensing` ^3.0.0 + ## 2.1.0 * require `carp_serializable` ^3.0.0, which replaces the built-in `Uuid` with the [uuid](https://pub.dev/packages/uuid) package diff --git a/packages/carp_movisens_package/pubspec.yaml b/packages/carp_movisens_package/pubspec.yaml index 907b035ca..a7aa51825 100644 --- a/packages/carp_movisens_package/pubspec.yaml +++ b/packages/carp_movisens_package/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_movisens_package -version: 2.1.0 +version: 3.0.0 description: CARP Movisens sampling package. Samples movement, activity, HRV, MET-level, and ECG for the Movisens Move4 and EcgMove4 devices homepage: https://github.com/cph-cachet/carp.sensing-flutter repository: https://github.com/cph-cachet/carp.sensing-flutter/tree/main/packages/carp_movisens_package @@ -26,7 +26,7 @@ dependencies: carp_serializable: ^3.0.0 carp_core: ^2.2.0 - carp_mobile_sensing: ^2.2.0 + carp_mobile_sensing: ^3.0.0 json_annotation: ^4.8.0 async: ^2.7.0 diff --git a/packages/carp_polar_package/CHANGELOG.md b/packages/carp_polar_package/CHANGELOG.md index 2d98159ea..3b8459dc2 100644 --- a/packages/carp_polar_package/CHANGELOG.md +++ b/packages/carp_polar_package/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0 + +* require `carp_mobile_sensing` ^3.0.0 + ## 2.1.0 * require `carp_serializable` ^3.0.0, which replaces the built-in `Uuid` with the [uuid](https://pub.dev/packages/uuid) package diff --git a/packages/carp_polar_package/pubspec.yaml b/packages/carp_polar_package/pubspec.yaml index 7ef5b75a2..99430a219 100644 --- a/packages/carp_polar_package/pubspec.yaml +++ b/packages/carp_polar_package/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_polar_package -version: 2.1.0 +version: 3.0.0 description: The CARP Polar sampling package. Samples sensor data from the Polar H9, H10, and Verity Sense devices. homepage: https://github.com/cph-cachet/carp.sensing-flutter repository: https://github.com/cph-cachet/carp.sensing-flutter/tree/main/packages/carp_polar_package @@ -26,7 +26,7 @@ dependencies: carp_serializable: ^3.0.0 carp_core: ^2.2.0 - carp_mobile_sensing: ^2.2.0 + carp_mobile_sensing: ^3.0.0 json_annotation: ^4.8.0 polar: ^7.0.0 diff --git a/packages/carp_survey_package/CHANGELOG.md b/packages/carp_survey_package/CHANGELOG.md index 76a152824..2bd9e90e8 100644 --- a/packages/carp_survey_package/CHANGELOG.md +++ b/packages/carp_survey_package/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.0.0 + +* require `carp_mobile_sensing` ^3.0.0 + ## 2.1.1 - upgrade to `research_package` ^3.0.0 and `cognition_package` ^1.9.0 diff --git a/packages/carp_survey_package/pubspec.yaml b/packages/carp_survey_package/pubspec.yaml index 0a488a2b8..901f73adc 100644 --- a/packages/carp_survey_package/pubspec.yaml +++ b/packages/carp_survey_package/pubspec.yaml @@ -1,5 +1,5 @@ name: carp_survey_package -version: 2.1.1 +version: 3.0.0 description: CARP survey sampling package. Samples survey data from the CARP Research Package and Cognition Package. homepage: https://github.com/cph-cachet/carp.sensing-flutter repository: https://github.com/cph-cachet/carp.sensing-flutter/tree/master/packages/carp_survey_package @@ -26,7 +26,7 @@ dependencies: carp_serializable: ^3.0.0 carp_core: ^2.2.0 - carp_mobile_sensing: ^2.3.0 + carp_mobile_sensing: ^3.0.0 research_package: ^3.0.0 cognition_package: ^1.9.0 From 546f119ddb9514acf3739ec63a6ad7689ed15f07 Mon Sep 17 00:00:00 2001 From: Miklos Havlik Date: Tue, 25 Aug 2026 08:45:16 +0200 Subject: [PATCH 2/3] fix(cams): ask device permissions when the user connects, not on deployment Three concurrent askers made permission dialogs fail silently: the deployment handler fires twice per launch, the location plugin requested background location natively, and probes initialized mid-ask. Android denies - without showing - any request made while a dialog is up. Now there is exactly one moment for each permission: - study-wide permissions (notification, measures) are asked when the deployment is configured, before probes initialize - device permissions are asked when the *user* connects the device, via requestPermissions() + connect() from the app UI connect() only checks - never asks - so the auto-connect paths (deployment, task start) can no longer pop dialogs unprompted. An ungranted device stays disconnected until the user connects it; the choice is persisted and later launches reconnect silently. Behaviour change: permission-guarded devices no longer sample until the user connects them once. Apps without a device-connect UI must add one. Also: LocationManager never initiates permission UI (background mode is enabled only once locationAlways is granted), the deployment handler is serialized against double-firing, and a failed permission request or deployment no longer poisons the queue behind it. --- carp_mobile_sensing/CHANGELOG.md | 45 ++++++++-- .../lib/runtime/client_manager.dart | 12 ++- .../device_manager/device_manager.dart | 17 ++-- .../lib/runtime/permissions.dart | 14 ++- .../lib/runtime/study_controller.dart | 47 +++++++--- .../test/permissions_test.dart | 89 +++++++++++++++++-- packages/carp_context_package/CHANGELOG.md | 17 ++-- .../lib/src/location_manager.dart | 19 ++-- 8 files changed, 210 insertions(+), 50 deletions(-) diff --git a/carp_mobile_sensing/CHANGELOG.md b/carp_mobile_sensing/CHANGELOG.md index f29c24cb2..406a73313 100644 --- a/carp_mobile_sensing/CHANGELOG.md +++ b/carp_mobile_sensing/CHANGELOG.md @@ -1,21 +1,49 @@ ## 3.0.0 -Permissions are now declared as data and requested in one place, before devices connect. +Permissions are now declared as data, and requested at the moment they are about to +be used - study-wide permissions when the deployment is configured, device permissions +when the *user* connects the device. + +**Who asks, when** + +| Permission for | Asked when | Asked by | +|---|---|---| +| notifying app tasks (`notification`) | deployment configured | `SmartphoneStudyController` | +| measures on this phone (e.g. `activityRecognition`) | deployment configured | `SmartphoneStudyController` | +| a device/service (e.g. `locationAlways`) | the user connects it | the app UI, via `DeviceManager.requestPermissions()` then `connect()` | + +CAMS auto-connects devices on deployment and on task start; those paths *check* +permissions and never ask, so no dialog can appear unprompted. All requests go through +one serialized queue - Android denies, without showing, any permission request that +arrives while another dialog is up, which used to make dialogs "fail silently". + +**Behaviour change - devices no longer sample until the user connects them** + +Previously a deployment tried to obtain all its permissions up front, and every +device with granted permissions started sampling automatically. Now a device whose +permissions have not been granted stays `disconnected` - silently, by design - until +the user connects it from the app (which asks first). **If your app has no UI for +connecting devices, location/weather/air quality and other permission-guarded devices +will never start.** Once connected, `isConnected` is persisted in the device +registration and later launches reconnect silently - the dialog is a one-time, +user-initiated event. **Fixes** +* permission dialogs no longer fail silently. Three code paths asked concurrently + (the deployment handler fires twice per launch, the location plugin asked natively, + probes initialized mid-ask); Android bounces every request made while a dialog is + up, returning `denied` without showing anything * no more "allow alarms & reminders" dialog on first launch - notifications are scheduled exactly when `SCHEDULE_EXACT_ALARM` happens to be granted, and inexactly (still delivered while the phone is idle, within minutes) when it is not. Drop `SCHEDULE_EXACT_ALARM` from your manifest unless your study truly needs to-the-second reminders * the Android location ladder works: `locationAlways` is asked only after `locationWhenInUse`, in its own dialog, whatever order a study declares them in -* permissions are requested *before* devices connect. Previously devices connected first, - failed their permission check, and stayed paused until the app was restarted. - Only the devices actually being connected are asked about - a wearable the participant - has not paired yet asks for its own permissions when it is paired * `Permission.notification` is asked for when a study with notifying app tasks starts, instead of at `configure()` before any study exists +* a failed permission request or deployment configuration no longer blocks the + requests/configurations queued behind it **Breaking** @@ -34,9 +62,10 @@ Permissions are now declared as data and requested in one place, before devices * `SmartPhoneClientManager.configure(askForPermissions: bool)` -> `configure(permissionRequester: PermissionRequester?)`. The default asks one dialog at a time; pass your own to show a rationale first, or `null` to handle permissions in the app -* `SmartphoneStudyController.askForAllPermissions()` removed - CAMS asks automatically. - The new `requiredPermissions` getter lists what a deployment needs, so an app can explain - it up front +* `SmartphoneStudyController.askForAllPermissions()` removed - study-wide permissions are + asked automatically at deployment; device permissions when the user connects the device. + The new `requiredPermissions` getter lists everything a deployment needs, so an app can + explain it up front * `SmartphoneStudyController.permissions` removed - it cached a status the OS can revoke at any time. Ask `permission_handler` instead * `Probe.requestPermissions()` and `Probe.arePermissionsGranted()` removed - probes check diff --git a/carp_mobile_sensing/lib/runtime/client_manager.dart b/carp_mobile_sensing/lib/runtime/client_manager.dart index 9aa8e9eff..664182461 100644 --- a/carp_mobile_sensing/lib/runtime/client_manager.dart +++ b/carp_mobile_sensing/lib/runtime/client_manager.dart @@ -61,9 +61,17 @@ class SmartPhoneClientManager /// /// Serialized: `permission_handler` forbids concurrent requests, and two /// studies can start at once. - Future requestPermissions(List permissions) async => + /// + /// A failed requester is logged, not rethrown: CAMS re-checks the actual + /// permission status afterwards anyway, and an error here must not block + /// the requests queued behind it. + Future requestPermissions(List permissions) => _asking = _asking.then((_) async { - await _permissionRequester?.call(permissions); + try { + await _permissionRequester?.call(permissions); + } catch (error) { + warning('$runtimeType - Permission requester failed - $error'); + } }); Future _asking = Future.value(); diff --git a/carp_mobile_sensing/lib/runtime/device_manager/device_manager.dart b/carp_mobile_sensing/lib/runtime/device_manager/device_manager.dart index f5de2b7f0..890065dc4 100644 --- a/carp_mobile_sensing/lib/runtime/device_manager/device_manager.dart +++ b/carp_mobile_sensing/lib/runtime/device_manager/device_manager.dart @@ -165,9 +165,10 @@ abstract class DeviceManager< /// The permissions this device needs. /// - /// Declared, not requested - the study controller collects the permissions of - /// every device and measure in a deployment and asks for them once, before - /// connecting anything. See [SmartphoneStudyController.requiredPermissions]. + /// Declared here, checked - never requested - by [connect]. Asking belongs + /// to the app's UI via [requestPermissions], at the moment the user chooses + /// to connect this device. CAMS auto-connects devices on deployment and on + /// task start; if those could ask, dialogs would appear unprompted. List get permissions => []; /// Does this device manager have the [permissions] to run? @@ -191,8 +192,8 @@ abstract class DeviceManager< /// Request all [permissions] for this device manager. /// - /// Only needed when connecting a device on demand, e.g. from a settings page. - /// Devices in a deployment have their permissions requested up front. + /// Call before [connect], when the user chooses to connect this device - + /// [connect] itself only checks. @nonVirtual Future requestPermissions() async { info('$runtimeType - Requesting permissions for device of type: $typeName.'); @@ -221,9 +222,13 @@ abstract class DeviceManager< status = DeviceStatus.connecting; + // Only check - never ask. CAMS connects devices automatically (on + // deployment, and when a task starts); a device the user has not granted + // yet simply stays disconnected until they connect it from the app, which + // asks via requestPermissions() first. if (!(await hasPermissions())) { warning( - '$runtimeType has not the permissions required to connect. ' + '$runtimeType does not have the permissions required to connect. ' 'Call requestPermissions() before calling connect.', ); return status = DeviceStatus.disconnected; diff --git a/carp_mobile_sensing/lib/runtime/permissions.dart b/carp_mobile_sensing/lib/runtime/permissions.dart index f080950b0..94930b0ad 100644 --- a/carp_mobile_sensing/lib/runtime/permissions.dart +++ b/carp_mobile_sensing/lib/runtime/permissions.dart @@ -31,8 +31,20 @@ Future requestPermissionsInOrder(List permissions) async { if (!asked.add(permission)) continue; if (await permission.isGranted) continue; + final name = permission.toString().split('.').last; + final asking = DateTime.now(); final status = await permission.request(); - info('Permission ${permission.toString().split('.').last}: ${status.name}'); + final took = DateTime.now().difference(asking); + + // The duration is the tell: a dialog the participant actually saw takes + // hundreds of ms at least. An instant answer means Android refused to show + // one - "Can request only one set of permissions at a time" - because + // something else was already asking. + info('Permission $name: ${status.name} (${took.inMilliseconds}ms)'); + if (took.inMilliseconds < 50 && status.isDenied) { + warning('Permission $name was denied without a dialog - another request ' + 'was already in progress.'); + } } } diff --git a/carp_mobile_sensing/lib/runtime/study_controller.dart b/carp_mobile_sensing/lib/runtime/study_controller.dart index 2225d59cc..bbd96c3e8 100644 --- a/carp_mobile_sensing/lib/runtime/study_controller.dart +++ b/carp_mobile_sensing/lib/runtime/study_controller.dart @@ -117,12 +117,30 @@ class SmartphoneStudyController { /// Handles updates of the [deployment] status. Future _deploymentStatusReceived() async {} + /// Serializes [_deviceDeploymentReceived], which the event stream can fire + /// again while a previous run is still awaiting - twice on a normal launch. + /// Two runs at once ask for the same permissions concurrently, and Android + /// bounces every request that arrives while a dialog is already up. + /// + /// A failed run is logged, not rethrown - nothing awaits the event handler, + /// and an error must not block the runs queued behind it. + Future _configuring = Future.value(); + /// Handles the reception of a new or updated [deployment]. /// /// This entails configuring devices, data manager, and executor to get /// ready to handle sampling of data. Data sampling is started if the /// [SmartphoneStudy.samplingState] is in a resumed state. - Future _deviceDeploymentReceived() async { + Future _deviceDeploymentReceived() => + _configuring = _configuring.then((_) async { + try { + await _configureDeployment(); + } catch (error) { + warning('$runtimeType - Configuring deployment failed - $error'); + } + }); + + Future _configureDeployment() async { debug( '$runtimeType - Received device deployment: ${deployment?.studyDeploymentId}', ); @@ -170,6 +188,16 @@ class SmartphoneStudyController { // Initialize all devices from the deployment, incl. this smartphone. _configureAllDevices(); + // Ask for the study-wide permissions - notifications and measures - before + // probes initialize. Device permissions are not asked here: each device + // asks for its own when it is connected, so the participant sees the + // dialog at the moment the device is actually put to use. + await SmartPhoneClientManager().requestPermissions([ + if (deployment!.hasNotifyingTask) Permission.notification, + for (final measure in deployment!.measures) + ...?_measurePermissions(measure), + ]); + // Initialize the executor, which recursively initializes all executors and probes. // But before doing this, save any existing sampling status which might have // been loaded, so that we can properly resume sampling. @@ -177,11 +205,6 @@ class SmartphoneStudyController { _executor.initialize(deployment!, deployment!); _executor.setSamplingState(existingSamplingStatus); - // Ask for the permissions this deployment needs. Must happen before - // connecting, since a device that lacks its permissions refuses to connect - // and nothing reconnects it afterwards. - await SmartPhoneClientManager().requestPermissions(requiredPermissions); - // Connect to all connectable devices. // (Re-)connecting a device will trigger that // - the device is re-registered with the deployment service, @@ -347,15 +370,11 @@ class SmartphoneStudyController { ); } - /// The permissions needed to start sampling this [deployment] now - those of - /// every measure, and of the devices about to be connected. - /// - /// Devices that are not ready to connect are left out: a wearable the - /// participant has not paired yet is not connected at study start, so asking - /// for its permissions now would be asking for something we are not about to - /// use. An app that lets the participant pair such a device later asks then, - /// via [DeviceManager.requestPermissions]. + /// The permissions needed to run this [deployment] - those of every measure, + /// and of the devices about to be connected. /// + /// Not requested in one go: measure permissions are asked when the deployment + /// is configured, and each device asks for its own when it is connected. /// Available once the deployment is received, so an app can show what a study /// needs (and why) before any system dialog appears. List get requiredPermissions => [ diff --git a/carp_mobile_sensing/test/permissions_test.dart b/carp_mobile_sensing/test/permissions_test.dart index 923246cdb..727cbc95d 100644 --- a/carp_mobile_sensing/test/permissions_test.dart +++ b/carp_mobile_sensing/test/permissions_test.dart @@ -47,8 +47,12 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); CarpMobileSensing.ensureInitialized(); - /// Fake the OS: records the calls, grants whatever is asked for. - List fakePermissionHandler({Set alreadyGranted = const {}}) { + /// Fake the OS: records the calls; grants whatever is asked for, unless + /// [denyAll] - then the user taps "Don't allow" on every dialog. + List fakePermissionHandler({ + Set alreadyGranted = const {}, + bool denyAll = false, + }) { final calls = []; final granted = {...alreadyGranted}; @@ -62,6 +66,7 @@ void main() { case 'requestPermissions': final requested = (call.arguments as List).cast(); calls.add('request(${requested.join(',')})'); + if (denyAll) return {for (final p in requested) p: _denied}; granted.addAll(requested); return {for (final p in requested) p: _granted}; default: @@ -153,14 +158,84 @@ void main() { expect(deploymentOf(protocol).hasNotifyingTask, isTrue); }); - test('a device without its permissions refuses to connect', () async { - fakePermissionHandler(); // nothing granted, nothing requested + test('concurrent permission requests never overlap', () async { + final calls = fakePermissionHandler(); + + // Deployment events, probes and devices all ask at once on a normal launch. + // Android shows one dialog at a time and denies - without showing - any + // request that arrives while another is up, so these must not overlap. + final client = SmartPhoneClientManager(); + final permissions = _LocationishDeviceManager().permissions; + await Future.wait([ + client.requestPermissions(permissions), + client.requestPermissions(permissions), + ]); + + // The later runs find them granted by the first, and ask for nothing. + expect(calls.where((call) => call.startsWith('request')), [ + 'request(${Permission.locationWhenInUse.value})', + 'request(${Permission.locationAlways.value})', + ]); + }); + + test('a failed permission request does not block later requests', () async { + // A request that throws must not poison the request queue - a study that + // fails must not block permissions for every study after it. + var requests = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + switch (call.method) { + case 'checkPermissionStatus': + return _denied; + case 'requestPermissions': + if (++requests == 1) throw PlatformException(code: 'ERROR'); + final asked = (call.arguments as List).cast(); + return {for (final p in asked) p: _granted}; + default: + return null; + } + }); + + final client = SmartPhoneClientManager(); + await client.requestPermissions([Permission.notification]); // throws inside + await client.requestPermissions([Permission.notification]); + + expect(requests, 2); + }); + + test('auto-connect never asks - an ungranted device stays disconnected', () async { + final calls = fakePermissionHandler(); // nothing granted yet + final device = _LocationishDeviceManager() + ..configure(DeviceConfiguration(roleName: 'test')); + + // CAMS connects devices automatically on deployment and task start. Those + // paths must not trigger a dialog - the device just stays disconnected + // until the user connects it from the app. + expect(await device.connect(), DeviceStatus.disconnected); + expect(calls.where((call) => call.startsWith('request')), isEmpty); + }); + + test('the user connecting a device asks first, then connects', () async { + final calls = fakePermissionHandler(); // nothing granted yet + final device = _LocationishDeviceManager() + ..configure(DeviceConfiguration(roleName: 'test')); + + // The app's connect button: ask, then connect. + await device.requestPermissions(); + expect(await device.connect(), DeviceStatus.connected); + + expect(calls.where((call) => call.startsWith('request')), [ + 'request(${Permission.locationWhenInUse.value})', + 'request(${Permission.locationAlways.value})', + ]); + }); + + test('a device denied its permissions refuses to connect', () async { + fakePermissionHandler(denyAll: true); final device = _LocationishDeviceManager() ..configure(DeviceConfiguration(roleName: 'test')); - // This is why permissions must be requested *before* connecting: a device - // that connects first sees no permissions, gives up, and nothing retries. - expect(await device.hasPermissions(), isFalse); + await device.requestPermissions(); // user taps connect, denies the dialog expect(await device.connect(), DeviceStatus.disconnected); }); diff --git a/packages/carp_context_package/CHANGELOG.md b/packages/carp_context_package/CHANGELOG.md index 750b683d5..1c28a2cc5 100644 --- a/packages/carp_context_package/CHANGELOG.md +++ b/packages/carp_context_package/CHANGELOG.md @@ -2,12 +2,17 @@ * require `carp_mobile_sensing` ^3.0.0 * location permissions are declared, not requested: the context services declare - `Permission.locationAlways` and CAMS asks for it - `locationWhenInUse` first - before - connecting them. Removes the three separate ways location used to be requested -* `LocationManager.requestPermission()` removed. Location is requested like any other - permission now; use `SmartPhoneClientManager().requestPermissions(...)` if you need to ask -* `LocationManager.configure()` no longer requests permission as a side effect - it bails out - when permission is missing, and configures when the service is connected again + `Permission.locationAlways`, and it is asked for - `locationWhenInUse` first - when the + *user* connects the service in the app. Removes the three separate ways location used + to be requested +* **behaviour change:** location, weather, and air quality no longer trigger a permission + dialog (or sample) on deployment. They stay disconnected until the user connects the + location service from the app; the choice is persisted and later launches reconnect + silently +* `LocationManager` no longer initiates any permission UI: `requestPermission()` removed, + `configure()` bails out when permission is missing, and `enable()` only enables the + plugin's background mode once `locationAlways` is already granted (the plugin used to + request it natively, colliding with other dialogs) * a location probe denied permission now stays paused instead of silently reporting success ## 2.1.0 diff --git a/packages/carp_context_package/lib/src/location_manager.dart b/packages/carp_context_package/lib/src/location_manager.dart index 4c1ac7c64..6d8ff3fdd 100644 --- a/packages/carp_context_package/lib/src/location_manager.dart +++ b/packages/carp_context_package/lib/src/location_manager.dart @@ -84,12 +84,16 @@ class LocationManager { /// /// After the location manager is enabled, configuration can be done via the /// [configure] method. + /// + /// Never asks for permission: called from a service's `onConnect()`, and CAMS + /// asks for a study's permissions before it connects anything. Asking from + /// here would collide with that - Android denies, without showing, any + /// permission request made while another one is up. Future enable() async { // fast out if already enabled if (enabled) return; info('Enabling $runtimeType...'); - _enabled = false; bool serviceEnabled = await _provider.serviceEnabled(); if (!serviceEnabled) { @@ -102,10 +106,14 @@ class LocationManager { _enabled = true; bool backgroundMode = false; - try { - backgroundMode = await _provider.enableBackgroundMode(); - } catch (error) { - warning('$runtimeType - Could not enable background mode - $error'); + // Only enable background mode once permission is granted - the plugin + // requests it natively otherwise, which is this manager's job to avoid. + if (await Permission.locationAlways.isGranted) { + try { + backgroundMode = await _provider.enableBackgroundMode(); + } catch (error) { + warning('$runtimeType - Could not enable background mode - $error'); + } } info('$runtimeType - Location service enabled, background mode: $backgroundMode'); @@ -128,7 +136,6 @@ class LocationManager { await enable(); info('Configuring $runtimeType - configuration: $configuration'); - _configured = false; // Only on Android, configure the notification shown when running in background. if (Platform.isAndroid) { From a088d6cceab4f874025410f69a39e4f241759c0b Mon Sep 17 00:00:00 2001 From: Miklos Havlik Date: Fri, 28 Aug 2026 16:29:24 +0200 Subject: [PATCH 3/3] feat(cams): own permissions in device managers, not data types Permissions were declared on data types, which made the domain layer import permission_handler and grouped unrelated capabilities together: a study collecting step count also asked for SMS and call-log access, because they rode on the same phone device. Device managers now declare their own permissions, and the phone's capabilities are split into services a protocol declares individually - ActivityService, MicrophoneService, CameraService, PhoneLogService, TextMessageService, CalendarService, BluetoothScanService - each following the existing LocationService pattern. Data types that moved to a service of their own would leave probes with no device to sample through, so addMissingServiceDevices() adds the services a deployment's measures need but its protocol does not declare. Protocols from before API level 3.0 keep working unchanged. Notification permission moves to SmartPhoneClientManager.configure(): it is the app's own permission, not any device's, and Android 13+ shows nothing at all without it. --- .../lib/carp_mobile_sensing.json.dart | 3 +- carp_mobile_sensing/lib/domain.dart | 5 +- .../lib/domain/core/data_types.dart | 31 +- .../lib/domain/core/smartphone_protocol.dart | 7 +- .../domain/services/notification_manager.dart | 4 +- .../sensors/activity_service.dart | 106 ++++++ .../sensors/sensor_package.dart | 20 -- .../lib/runtime/client_manager.dart | 7 + .../device_manager/device_manager.dart | 65 +++- .../lib/runtime/executors/probes.dart | 323 ++++++++---------- .../lib/runtime/permissions.dart | 6 +- .../runtime/sampling_package_registry.dart | 1 + .../lib/runtime/study_controller.dart | 82 +++-- .../lib/sampling_packages.dart | 1 + .../lib/sampling_packages.g.dart | 21 ++ .../test/api_level_1_compatibility_test.dart | 52 ++- .../test/json/study_deployment.json | 14 +- .../test/json/study_protocol.json | 12 +- .../test/permissions_test.dart | 21 ++ .../lib/media_services.dart | 90 +++++ .../lib/communication_services.dart | 129 +++++++ .../lib/connectivity_service.dart | 99 ++++++ 22 files changed, 799 insertions(+), 300 deletions(-) create mode 100644 carp_mobile_sensing/lib/infrastructure/sampling_packages/sensors/activity_service.dart create mode 100644 packages/carp_audio_package/lib/media_services.dart create mode 100644 packages/carp_communication_package/lib/communication_services.dart create mode 100644 packages/carp_connectivity_package/lib/connectivity_service.dart diff --git a/carp_mobile_sensing/lib/carp_mobile_sensing.json.dart b/carp_mobile_sensing/lib/carp_mobile_sensing.json.dart index 36a4fb74b..c75a33942 100644 --- a/carp_mobile_sensing/lib/carp_mobile_sensing.json.dart +++ b/carp_mobile_sensing/lib/carp_mobile_sensing.json.dart @@ -41,7 +41,8 @@ void _registerFromJsonFunctions() { ); FromJsonFactory().register( SmartphoneRegistration(), - type: '${DeviceConfiguration.DEVICE_NAMESPACE}.SmartphoneDeviceRegistration', + type: + '${DeviceConfiguration.DEVICE_NAMESPACE}.SmartphoneDeviceRegistration', ); // Task classes diff --git a/carp_mobile_sensing/lib/domain.dart b/carp_mobile_sensing/lib/domain.dart index b5850f1e7..ed8b36481 100644 --- a/carp_mobile_sensing/lib/domain.dart +++ b/carp_mobile_sensing/lib/domain.dart @@ -29,9 +29,10 @@ import 'package:carp_mobile_sensing/carp_mobile_sensing.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/cupertino.dart' show AppLifecycleState; -import 'package:permission_handler/permission_handler.dart'; -import 'package:device_info_plus/device_info_plus.dart'; import 'package:json_annotation/json_annotation.dart'; +// Needed only by the 'infrastructure/services/device_info_service.dart' part +// below, which is an infrastructure file living in this library. +import 'package:device_info_plus/device_info_plus.dart'; part 'domain/core/smartphone_protocol.dart'; part 'domain/core/study_description.dart'; diff --git a/carp_mobile_sensing/lib/domain/core/data_types.dart b/carp_mobile_sensing/lib/domain/core/data_types.dart index ddaf17273..91c52621f 100644 --- a/carp_mobile_sensing/lib/domain/core/data_types.dart +++ b/carp_mobile_sensing/lib/domain/core/data_types.dart @@ -20,47 +20,32 @@ enum DataEventType { /// /// In addition to core [DataTypeMetaData], which stores the [type], [displayName], /// and [timeType] of the data, this [CamsDataTypeMetaData] also stores -/// information on [dataEventType] and what [permissions] are needed on -/// runtime to collect this data type. +/// information on the [dataEventType]. +/// +/// Note that a data type does **not** declare permissions. Permissions belong +/// to the device that collects the data - see [DeviceManager.permissions] - +/// and are requested when that device is connected. class CamsDataTypeMetaData extends DataTypeMetaData { /// How a data type is collected (one-time or event-based). DataEventType dataEventType; - /// The list of permissions that are required for this data type. - /// - /// Note that this is the list of permissions needed for the probe collecting - /// this data type. It **should not** include permission to access a device - /// itself, such as Bluetooth permissions. - /// Such permissions should be handled on the app level. - /// - /// See [PermissionGroup](https://pub.dev/documentation/permission_handler/latest/permission_handler/PermissionGroup-class.html) - /// for a list of possible permissions. - /// - /// For Android permission in the Manifest.xml file, - /// see [Manifest.permission](https://developer.android.com/reference/android/Manifest.permission.html) - List permissions; - /// Create a new description of a data [type] with some [displayName]. /// - /// Default [timeType] is [DataTimeType.POINT], - /// default [dataEventType] is [DataEventType.EVENT], and - /// default [permissions] is empty (no permissions required). + /// Default [timeType] is [DataTimeType.POINT] and + /// default [dataEventType] is [DataEventType.EVENT]. CamsDataTypeMetaData({ required super.type, super.displayName, super.timeType, this.dataEventType = DataEventType.EVENT, - this.permissions = const [], }); /// Create a new description of a data type based on the [dataTypeMetaData]. /// - /// Default [dataEventType] is [DataEventType.EVENT], and - /// default [permissions] is empty (no permissions required). + /// Default [dataEventType] is [DataEventType.EVENT]. CamsDataTypeMetaData.fromDataTypeMetaData({ required DataTypeMetaData dataTypeMetaData, this.dataEventType = DataEventType.EVENT, - this.permissions = const [], }) : super( type: dataTypeMetaData.type, displayName: dataTypeMetaData.displayName, diff --git a/carp_mobile_sensing/lib/domain/core/smartphone_protocol.dart b/carp_mobile_sensing/lib/domain/core/smartphone_protocol.dart index 24bb6146d..08b6941cd 100644 --- a/carp_mobile_sensing/lib/domain/core/smartphone_protocol.dart +++ b/carp_mobile_sensing/lib/domain/core/smartphone_protocol.dart @@ -161,7 +161,12 @@ class SmartphoneStudyProtocol extends StudyProtocol /// The API level used by study protocols. /// This reflects the **major** version of the CARP Mobile Sensing framework /// as set in the pubspec.yaml file. - static const String CAMS_PROTOCOL_API_LEVEL = '2.0'; + /// + /// From API level 3.0, a protocol declares the services its measures sample + /// through - `addConnectedDevice(ActivityService(), phone)` - which is what + /// makes the permissions a study needs visible in the protocol itself. + /// Protocols from earlier levels do not, and have them added on deployment. + static const String CAMS_PROTOCOL_API_LEVEL = '3.0'; // These static app names can be used as [applicationName] in the protocol. // It is the name of the Flutter app as specified in the pubspec.yaml file. diff --git a/carp_mobile_sensing/lib/domain/services/notification_manager.dart b/carp_mobile_sensing/lib/domain/services/notification_manager.dart index 20cf69a3d..b857efafb 100644 --- a/carp_mobile_sensing/lib/domain/services/notification_manager.dart +++ b/carp_mobile_sensing/lib/domain/services/notification_manager.dart @@ -51,7 +51,9 @@ abstract class NotificationManager { 'Notifications about scheduled tasks that the user has to do.'; /// Configure and set up the notification manager. - /// Also tries to get permissions to send notifications. + /// + /// Does not ask for permission to notify - [SmartPhoneClientManager.configure] + /// does, before calling this. Future configure(); /// Create an immediate notification with [id], [title], and [body]. diff --git a/carp_mobile_sensing/lib/infrastructure/sampling_packages/sensors/activity_service.dart b/carp_mobile_sensing/lib/infrastructure/sampling_packages/sensors/activity_service.dart new file mode 100644 index 000000000..d697723cd --- /dev/null +++ b/carp_mobile_sensing/lib/infrastructure/sampling_packages/sensors/activity_service.dart @@ -0,0 +1,106 @@ +/* + * Copyright 2026 the Technical University of Denmark (DTU). + * Use of this source code is governed by a MIT-style license that can be + * found in the LICENSE file. + */ + +part of '../../../sampling_packages.dart'; + +/// A [ServiceConfiguration] for the phone's activity recognition service. +/// +/// Add it to a protocol - `addConnectedDevice(ActivityService(), phone)` - to +/// collect step events. Activity recognition is a permission of its own, so it +/// is a service of its own: a study that does not deploy it can never ask for it. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class ActivityService extends ServiceConfiguration { + static const String DEVICE_TYPE = + '${CamsDevice.CAMS_DEVICE_NAMESPACE}.ActivityService'; + static const String DEFAULT_ROLE_NAME = 'Activity Service'; + + ActivityService({String? roleName}) + : super(roleName: roleName ?? DEFAULT_ROLE_NAME); + + @override + Function get fromJsonFunction => _$ActivityServiceFromJson; + factory ActivityService.fromJson(Map json) => + FromJsonFactory().fromJson(json); + @override + Map toJson() => _$ActivityServiceToJson(this); +} + +/// A [DeviceManager] for the phone's activity recognition service. +/// +/// A singleton - the activity service is one service, even though several +/// sampling packages collect from it (step events here, activity recognition +/// in the context package). +class ActivityServiceManager + extends ServiceManager { + static final ActivityServiceManager _instance = ActivityServiceManager._(); + factory ActivityServiceManager() => _instance; + ActivityServiceManager._() : super(ActivityService.DEVICE_TYPE); + + @override + String? get displayName => 'Activity Recognition'; + + @override + List get permissions => [Permission.activityRecognition]; + + @override + ServiceRegistration createRegistration() => + ServiceRegistration(deviceDisplayName: displayName); + + @override + bool get canConnect => true; + + @override + void onConfigure() {} + + @override + Future onConnect() async => DeviceStatus.connected; + + @override + Future onDisconnect() async => true; +} + +/// A sampling package for collecting step events from the phone's pedometer. +/// +/// Registered by [SensorSamplingPackage] - its data types run on the +/// [ActivityService], not on the phone itself. +class ActivitySamplingPackage extends SmartphoneSamplingPackage { + final _deviceManager = ActivityServiceManager(); + + @override + String get deviceType => ActivityService.DEVICE_TYPE; + + @override + DeviceManager get deviceManager => _deviceManager; + + @override + DataTypeSamplingSchemeMap get samplingSchemes => + DataTypeSamplingSchemeMap.from([ + DataTypeSamplingScheme( + CamsDataTypeMetaData( + type: SensorSamplingPackage.STEP_EVENT, + displayName: "Step Events", + timeType: DataTimeType.POINT, + ), + ), + DataTypeSamplingScheme( + CamsDataTypeMetaData( + type: SensorSamplingPackage.STEP_COUNT, + displayName: "Step Count", + timeType: DataTimeType.POINT, + ), + ), + ]); + + @override + Probe? create(String type) => switch (type) { + SensorSamplingPackage.STEP_EVENT => PedometerProbe(), + SensorSamplingPackage.STEP_COUNT => StepCountProbe(), + _ => null, + }; + + @override + void onRegister() => FromJsonFactory().register(ActivityService()); +} diff --git a/carp_mobile_sensing/lib/infrastructure/sampling_packages/sensors/sensor_package.dart b/carp_mobile_sensing/lib/infrastructure/sampling_packages/sensors/sensor_package.dart index 5fcc31f4d..76e5e16cd 100644 --- a/carp_mobile_sensing/lib/infrastructure/sampling_packages/sensors/sensor_package.dart +++ b/carp_mobile_sensing/lib/infrastructure/sampling_packages/sensors/sensor_package.dart @@ -113,22 +113,6 @@ class SensorSamplingPackage extends SmartphoneSamplingPackage { duration: const Duration(seconds: 3), ), ), - DataTypeSamplingScheme( - CamsDataTypeMetaData( - type: STEP_EVENT, - displayName: "Step Events", - timeType: DataTimeType.POINT, - permissions: [Permission.activityRecognition], - ), - ), - DataTypeSamplingScheme( - CamsDataTypeMetaData( - type: STEP_COUNT, - displayName: "Step Count", - timeType: DataTimeType.POINT, - permissions: [Permission.activityRecognition], - ), - ), DataTypeSamplingScheme( CamsDataTypeMetaData( type: AMBIENT_LIGHT, @@ -155,10 +139,6 @@ class SensorSamplingPackage extends SmartphoneSamplingPackage { return MagnetometerProbe(); case ROTATION: return GyroscopeProbe(); - case STEP_EVENT: - return PedometerProbe(); - case STEP_COUNT: - return StepCountProbe(); case AMBIENT_LIGHT: return (Platform.isAndroid) ? LightProbe() : null; default: diff --git a/carp_mobile_sensing/lib/runtime/client_manager.dart b/carp_mobile_sensing/lib/runtime/client_manager.dart index 664182461..75eee4c8a 100644 --- a/carp_mobile_sensing/lib/runtime/client_manager.dart +++ b/carp_mobile_sensing/lib/runtime/client_manager.dart @@ -208,6 +208,13 @@ class SmartPhoneClientManager } // Configure the notification manager. + // + // Notifications are the one permission that is not a device's: it belongs + // to the app itself, and is asked for here - if notifications are enabled - + // since Android 13+ shows nothing at all without it. + if (enableNotifications) { + await requestPermissions([Permission.notification]); + } await notificationManager.configure(); // Initialize the app task controller. diff --git a/carp_mobile_sensing/lib/runtime/device_manager/device_manager.dart b/carp_mobile_sensing/lib/runtime/device_manager/device_manager.dart index 890065dc4..e5b874a90 100644 --- a/carp_mobile_sensing/lib/runtime/device_manager/device_manager.dart +++ b/carp_mobile_sensing/lib/runtime/device_manager/device_manager.dart @@ -50,7 +50,8 @@ abstract class DeviceManager< TRegistration extends DeviceRegistration > implements ConnectedDeviceDataCollector { - final StreamController _eventController = StreamController.broadcast(); + final StreamController _eventController = + StreamController.broadcast(); DeviceStatus _status = DeviceStatus.unknown; final String _deviceType; @@ -67,7 +68,10 @@ abstract class DeviceManager< @override Set get supportedDataTypes => - configuration?.supportedDataTypes?.map((str) => DataType.fromString(str)).toSet() ?? {}; + configuration?.supportedDataTypes + ?.map((str) => DataType.fromString(str)) + .toSet() ?? + {}; /// The type of the device managed by this device manager String get deviceType => _deviceType; @@ -98,8 +102,9 @@ abstract class DeviceManager< /// Indicates whether this device manager should connect to the real device /// based on the last known registration information. /// Returns true (default) if no prior registration information is available, - bool get shouldConnect => - registration is CamsDeviceRegistration ? (registration as CamsDeviceRegistration).isConnected : true; + bool get shouldConnect => registration is CamsDeviceRegistration + ? (registration as CamsDeviceRegistration).isConnected + : true; /// The set of task control executors that use this device manager. final Set executors = {}; @@ -127,20 +132,28 @@ abstract class DeviceManager< /// Is this device manager connecting or already connected to a device? bool get isConnecting => - status == DeviceStatus.connected || status == DeviceStatus.reconnected || status == DeviceStatus.connecting; + status == DeviceStatus.connected || + status == DeviceStatus.reconnected || + status == DeviceStatus.connecting; /// Is this device manager connected to the real device? - bool get isConnected => status == DeviceStatus.connected || status == DeviceStatus.reconnected; + bool get isConnected => + status == DeviceStatus.connected || status == DeviceStatus.reconnected; /// Configure this device manager by specifying its [configuration]. /// Optionally, a [registration] can be specified to provide runtime information /// about the real device, e.g., the BLE address of a Bluetooth device. @nonVirtual - void configure(TDeviceConfiguration configuration, [TRegistration? registration]) { + void configure( + TDeviceConfiguration configuration, [ + TRegistration? registration, + ]) { // fast out if already configured if (isConfigured) return; - info('$runtimeType - Configuring, type: $typeName, configuration: $configuration, registration: $registration'); + info( + '$runtimeType - Configuring, type: $typeName, configuration: $configuration, registration: $registration', + ); _configuration = configuration; _registration = registration; @@ -148,9 +161,15 @@ abstract class DeviceManager< // A device connecting after the study has started has its executors paused, // and nothing else resumes them. - statusEvents.where((status) => status == DeviceStatus.connected).listen((_) => start()); - statusEvents.where((status) => status == DeviceStatus.disconnecting).listen((_) => isDisconnecting()); - statusEvents.where((status) => status == DeviceStatus.reconnected).listen((_) => restart()); + statusEvents + .where((status) => status == DeviceStatus.connected) + .listen((_) => start()); + statusEvents + .where((status) => status == DeviceStatus.disconnecting) + .listen((_) => isDisconnecting()); + statusEvents + .where((status) => status == DeviceStatus.reconnected) + .listen((_) => restart()); status = DeviceStatus.configured; } @@ -196,7 +215,9 @@ abstract class DeviceManager< /// [connect] itself only checks. @nonVirtual Future requestPermissions() async { - info('$runtimeType - Requesting permissions for device of type: $typeName.'); + info( + '$runtimeType - Requesting permissions for device of type: $typeName.', + ); await onRequestPermissions(); } @@ -238,7 +259,9 @@ abstract class DeviceManager< try { status = await onConnect(); } catch (error) { - warning('$runtimeType - Error connecting to device of type: $typeName. $error'); + warning( + '$runtimeType - Error connecting to device of type: $typeName. $error', + ); status = DeviceStatus.disconnected; } @@ -271,7 +294,9 @@ abstract class DeviceManager< info('$runtimeType - Restarting sampling...'); for (var executor in executors) { - debug('$runtimeType - Restarting executor: $executor, state: ${executor.state}'); + debug( + '$runtimeType - Restarting executor: $executor, state: ${executor.state}', + ); if (executor.state == ExecutorState.PausedButShouldBeResumed) { // resume data sampling with a delay to give the device some time to fully reconnect Future.delayed(const Duration(seconds: 15), () => executor.resume()); @@ -292,7 +317,9 @@ abstract class DeviceManager< /// sampling when the device is reconnected. @nonVirtual void stop({bool shouldBeResumed = false}) { - debug('$runtimeType - Stopping sampling - shouldResumeLater: $shouldBeResumed ...'); + debug( + '$runtimeType - Stopping sampling - shouldResumeLater: $shouldBeResumed ...', + ); for (var executor in executors) { executor.state == ExecutorState.Resumed && shouldBeResumed ? executor.pauseButShouldBeResumed() @@ -309,7 +336,9 @@ abstract class DeviceManager< @nonVirtual Future disconnect() async { if (!isConnecting) { - warning('$runtimeType is not connected, so nothing to disconnect from....'); + warning( + '$runtimeType is not connected, so nothing to disconnect from....', + ); return true; } bool success = false; @@ -320,7 +349,9 @@ abstract class DeviceManager< try { success = await onDisconnect(); } catch (error) { - warning('$runtimeType - Error disconnecting from device of type: $typeName. $error'); + warning( + '$runtimeType - Error disconnecting from device of type: $typeName. $error', + ); } status = (success) ? DeviceStatus.disconnected : status; diff --git a/carp_mobile_sensing/lib/runtime/executors/probes.dart b/carp_mobile_sensing/lib/runtime/executors/probes.dart index 6ae2692d3..fea1cabd2 100644 --- a/carp_mobile_sensing/lib/runtime/executors/probes.dart +++ b/carp_mobile_sensing/lib/runtime/executors/probes.dart @@ -10,10 +10,8 @@ part of '../../runtime.dart'; /// A [Probe] is a specialized [Executor] responsible for collecting data from /// the device sensors as configured in a [Measure]. /// -/// A probe may need a set of [permissions] to run. It never asks for them -/// itself - they are requested for the deployment as a whole, before sampling -/// starts. A probe without its permissions does not resume, and is resumed -/// later when its device (re)connects. +/// A probe knows nothing about permissions: it samples through its +/// [deviceManager], which is only connected once its permissions are granted. abstract class Probe extends AbstractExecutor { /// The device that this probes uses to collect data. late DeviceManager deviceManager; @@ -65,31 +63,6 @@ abstract class Probe extends AbstractExecutor { super.addMeasurement(measurement); } - /// The permissions needed for this probe to run, as declared by its - /// [CamsDataTypeMetaData]. - List get permissions { - final dataType = SamplingPackageRegistry().samplingSchemes[type]?.dataType; - return dataType is CamsDataTypeMetaData ? dataType.permissions : const []; - } - - /// Whether this probe is allowed to run. - /// - /// Probes only check. The permissions of a whole deployment are requested up - /// front, before its devices connect - see - /// [SmartphoneStudyController.requiredPermissions]. On iOS there is nothing - /// to check: permissions are requested when a resource is first accessed. - Future hasRequiredPermissions() async { - if (Platform.isIOS) return true; - - for (final permission in permissions) { - if (!await permission.isGranted) { - warning('$runtimeType - Missing permission: $permission'); - return false; - } - } - return true; - } - // default no-op implementation of callback methods below @override @@ -118,20 +91,16 @@ class StubProbe extends Probe {} abstract class MeasurementProbe extends Probe { @override Future onResume() async { - if (await hasRequiredPermissions()) { - getMeasurement().then( - (measurement) { - if (measurement != null) addMeasurement(measurement); - // automatically stop this probe after it is done collecting the measurement - Future.delayed(const Duration(seconds: 5), () => pause()); - }, - onError: (Object error, StackTrace? stackTrace) => - addError(error, stackTrace), - ); - return true; - } else { - return false; - } + getMeasurement().then( + (measurement) { + if (measurement != null) addMeasurement(measurement); + // automatically stop this probe after it is done collecting the measurement + Future.delayed(const Duration(seconds: 5), () => pause()); + }, + onError: (Object error, StackTrace? stackTrace) => + addError(error, stackTrace), + ); + return true; } /// Subclasses should implement this method to collect a [Measurement]. @@ -155,28 +124,24 @@ abstract class IntervalProbe extends MeasurementProbe { @override Future onResume() async { - if (await hasRequiredPermissions()) { - Duration? interval = samplingConfiguration?.interval; - if (interval != null) { - _timer ??= Timer.periodic(interval, (_) async { - try { - var measurement = await getMeasurement(); - if (measurement != null) addMeasurement(measurement); - } catch (error) { - addError(error); - } - }); - } else { - warning( - '$runtimeType - no valid interval found in sampling configuration: $samplingConfiguration. ' - 'Is a valid IntervalSamplingConfiguration provided?', - ); - return false; - } - return true; + Duration? interval = samplingConfiguration?.interval; + if (interval != null) { + _timer ??= Timer.periodic(interval, (_) async { + try { + var measurement = await getMeasurement(); + if (measurement != null) addMeasurement(measurement); + } catch (error) { + addError(error); + } + }); } else { + warning( + '$runtimeType - no valid interval found in sampling configuration: $samplingConfiguration. ' + 'Is a valid IntervalSamplingConfiguration provided?', + ); return false; } + return true; } @override @@ -207,28 +172,24 @@ abstract class StreamProbe extends Probe { @override Future onResume() async { - if (await hasRequiredPermissions()) { - _stream ??= stream; - if (_stream == null) { - warning( - "Trying to start the stream probe '$runtimeType' which does not provide a measurement stream. " - 'Have you initialized this probe correctly or is the device connected?', - ); - return false; - } else { - // Resuming an already resumed probe would otherwise orphan the old - // subscription, which keeps delivering. - await _subscription?.cancel(); - _subscription = _stream?.listen( - _onData, - onError: _onError, - onDone: _onDone, - ); - } - return true; - } else { + _stream ??= stream; + if (_stream == null) { + warning( + "Trying to start the stream probe '$runtimeType' which does not provide a measurement stream. " + 'Have you initialized this probe correctly or is the device connected?', + ); return false; + } else { + // Resuming an already resumed probe would otherwise orphan the old + // subscription, which keeps delivering. + await _subscription?.cancel(); + _subscription = _stream?.listen( + _onData, + onError: _onError, + onDone: _onDone, + ); } + return true; } @override @@ -266,38 +227,34 @@ abstract class PeriodicStreamProbe extends StreamProbe { @override Future onResume() async { - if (await hasRequiredPermissions()) { - if (stream == null) { + if (stream == null) { + warning( + "Trying to start the stream probe '$runtimeType' which does not provide a measurement stream. " + 'Have you initialized this probe correctly?', + ); + return false; + } else { + Duration? interval = samplingConfiguration?.interval; + Duration? duration = samplingConfiguration?.duration; + if (interval != null && duration != null) { + // create a recurrent timer that starts sampling + _timer = Timer.periodic(interval, (timer) { + _subscription = stream?.listen( + _onData, + onError: _onError, + onDone: _onDone, + ); + // create a timer that stops the sampling after the specified duration. + Timer(duration, () async => await _subscription?.cancel()); + }); + } else { warning( - "Trying to start the stream probe '$runtimeType' which does not provide a measurement stream. " - 'Have you initialized this probe correctly?', + '$runtimeType - no valid interval and duration found in sampling configuration: $samplingConfiguration. ' + 'Is a valid PeriodicSamplingConfiguration provided?', ); - return false; - } else { - Duration? interval = samplingConfiguration?.interval; - Duration? duration = samplingConfiguration?.duration; - if (interval != null && duration != null) { - // create a recurrent timer that starts sampling - _timer = Timer.periodic(interval, (timer) { - _subscription = stream?.listen( - _onData, - onError: _onError, - onDone: _onDone, - ); - // create a timer that stops the sampling after the specified duration. - Timer(duration, () async => await _subscription?.cancel()); - }); - } else { - warning( - '$runtimeType - no valid interval and duration found in sampling configuration: $samplingConfiguration. ' - 'Is a valid PeriodicSamplingConfiguration provided?', - ); - } } - return true; - } else { - return false; } + return true; } @override @@ -326,36 +283,32 @@ abstract class BufferingPeriodicProbe extends MeasurementProbe { @override Future onResume() async { - if (await hasRequiredPermissions()) { - Duration? interval = samplingConfiguration?.interval; - Duration? duration = samplingConfiguration?.duration; - if (interval != null && duration != null) { - // create a recurrent timer that every [interval] starts the buffering - timer = Timer.periodic(interval, (Timer t) { - onSamplingStart(); - // create a timer that stops the buffering after the specified [duration]. - Timer(duration, () async { - onSamplingEnd(); - // collect the measurement - try { - Measurement? measurement = await getMeasurement(); - if (measurement != null) addMeasurement(measurement); - } catch (error) { - addError(error); - } - }); + Duration? interval = samplingConfiguration?.interval; + Duration? duration = samplingConfiguration?.duration; + if (interval != null && duration != null) { + // create a recurrent timer that every [interval] starts the buffering + timer = Timer.periodic(interval, (Timer t) { + onSamplingStart(); + // create a timer that stops the buffering after the specified [duration]. + Timer(duration, () async { + onSamplingEnd(); + // collect the measurement + try { + Measurement? measurement = await getMeasurement(); + if (measurement != null) addMeasurement(measurement); + } catch (error) { + addError(error); + } }); - } else { - warning( - '$runtimeType - no valid interval and duration found in sampling configuration: $samplingConfiguration. ' - 'Is a valid PeriodicSamplingConfiguration provided?', - ); - return false; - } - return true; + }); } else { + warning( + '$runtimeType - no valid interval and duration found in sampling configuration: $samplingConfiguration. ' + 'Is a valid PeriodicSamplingConfiguration provided?', + ); return false; } + return true; } @override @@ -418,33 +371,29 @@ abstract class BufferingIntervalStreamProbe extends StreamProbe { @override Future onResume() async { - if (await hasRequiredPermissions()) { - Duration? interval = samplingConfiguration?.interval; - if (interval != null) { - _bufferingStreamSubscription = bufferingStream.listen( - onSamplingData, - onError: _onError, - onDone: _onDone, - ); - _timer = Timer.periodic(interval, (_) async { - try { - Measurement? measurement = await getMeasurement(); - if (measurement != null) addMeasurement(measurement); - } catch (error) { - addError(error); - } - }); - } else { - warning( - '$runtimeType - no valid interval found in sampling configuration: $samplingConfiguration. ' - 'Is a valid IntervalSamplingConfiguration provided?', - ); - return false; - } - return true; + Duration? interval = samplingConfiguration?.interval; + if (interval != null) { + _bufferingStreamSubscription = bufferingStream.listen( + onSamplingData, + onError: _onError, + onDone: _onDone, + ); + _timer = Timer.periodic(interval, (_) async { + try { + Measurement? measurement = await getMeasurement(); + if (measurement != null) addMeasurement(measurement); + } catch (error) { + addError(error); + } + }); } else { + warning( + '$runtimeType - no valid interval found in sampling configuration: $samplingConfiguration. ' + 'Is a valid IntervalSamplingConfiguration provided?', + ); return false; } + return true; } @override @@ -507,39 +456,35 @@ abstract class BufferingPeriodicStreamProbe extends PeriodicStreamProbe { @override Future onResume() async { - if (await hasRequiredPermissions()) { - Duration? interval = samplingConfiguration?.interval; - Duration? duration = samplingConfiguration?.duration; - if (interval != null && duration != null) { - _timer = Timer.periodic(interval, (Timer t) { - onSamplingStart(); - _bufferingStreamSubscription = bufferingStream.listen( - onSamplingData, - onError: _onError, - onDone: _onDone, - ); - _durationTimer = Timer(duration, () async { - await _bufferingStreamSubscription?.cancel(); - onSamplingEnd(); - try { - Measurement? measurement = await getMeasurement(); - if (measurement != null) addMeasurement(measurement); - } catch (error) { - addError(error); - } - }); - }); - } else { - warning( - '$runtimeType - no valid interval and duration found in sampling configuration: $samplingConfiguration. ' - 'Is a valid PeriodicSamplingConfiguration provided?', + Duration? interval = samplingConfiguration?.interval; + Duration? duration = samplingConfiguration?.duration; + if (interval != null && duration != null) { + _timer = Timer.periodic(interval, (Timer t) { + onSamplingStart(); + _bufferingStreamSubscription = bufferingStream.listen( + onSamplingData, + onError: _onError, + onDone: _onDone, ); - return false; - } - return true; + _durationTimer = Timer(duration, () async { + await _bufferingStreamSubscription?.cancel(); + onSamplingEnd(); + try { + Measurement? measurement = await getMeasurement(); + if (measurement != null) addMeasurement(measurement); + } catch (error) { + addError(error); + } + }); + }); } else { + warning( + '$runtimeType - no valid interval and duration found in sampling configuration: $samplingConfiguration. ' + 'Is a valid PeriodicSamplingConfiguration provided?', + ); return false; } + return true; } @override diff --git a/carp_mobile_sensing/lib/runtime/permissions.dart b/carp_mobile_sensing/lib/runtime/permissions.dart index 94930b0ad..3a5c6d501 100644 --- a/carp_mobile_sensing/lib/runtime/permissions.dart +++ b/carp_mobile_sensing/lib/runtime/permissions.dart @@ -42,8 +42,10 @@ Future requestPermissionsInOrder(List permissions) async { // something else was already asking. info('Permission $name: ${status.name} (${took.inMilliseconds}ms)'); if (took.inMilliseconds < 50 && status.isDenied) { - warning('Permission $name was denied without a dialog - another request ' - 'was already in progress.'); + warning( + 'Permission $name was denied without a dialog - another request ' + 'was already in progress.', + ); } } } diff --git a/carp_mobile_sensing/lib/runtime/sampling_package_registry.dart b/carp_mobile_sensing/lib/runtime/sampling_package_registry.dart index 8a20df154..68649663f 100644 --- a/carp_mobile_sensing/lib/runtime/sampling_package_registry.dart +++ b/carp_mobile_sensing/lib/runtime/sampling_package_registry.dart @@ -30,6 +30,7 @@ class SamplingPackageRegistry { // register the built-in packages register(DeviceSamplingPackage()); register(SensorSamplingPackage()); + register(ActivitySamplingPackage()); register(MonitoringSamplingPackage()); } diff --git a/carp_mobile_sensing/lib/runtime/study_controller.dart b/carp_mobile_sensing/lib/runtime/study_controller.dart index bbd96c3e8..2fd070ad5 100644 --- a/carp_mobile_sensing/lib/runtime/study_controller.dart +++ b/carp_mobile_sensing/lib/runtime/study_controller.dart @@ -188,16 +188,6 @@ class SmartphoneStudyController { // Initialize all devices from the deployment, incl. this smartphone. _configureAllDevices(); - // Ask for the study-wide permissions - notifications and measures - before - // probes initialize. Device permissions are not asked here: each device - // asks for its own when it is connected, so the participant sees the - // dialog at the moment the device is actually put to use. - await SmartPhoneClientManager().requestPermissions([ - if (deployment!.hasNotifyingTask) Permission.notification, - for (final measure in deployment!.measures) - ...?_measurePermissions(measure), - ]); - // Initialize the executor, which recursively initializes all executors and probes. // But before doing this, save any existing sampling status which might have // been loaded, so that we can properly resume sampling. @@ -370,17 +360,13 @@ class SmartphoneStudyController { ); } - /// The permissions needed to run this [deployment] - those of every measure, - /// and of the devices about to be connected. + /// The permissions needed to run this [deployment] - those of every device + /// in it. /// - /// Not requested in one go: measure permissions are asked when the deployment - /// is configured, and each device asks for its own when it is connected. - /// Available once the deployment is received, so an app can show what a study - /// needs (and why) before any system dialog appears. + /// Not requested in one go: each device asks for its own when it is + /// connected. Available once the deployment is received, so an app can show + /// what a study needs (and why) before any system dialog appears. List get requiredPermissions => [ - if (deployment?.hasNotifyingTask ?? false) Permission.notification, - for (final measure in deployment?.measures ?? []) - ...?_measurePermissions(measure), for (final device in _devicesToConnect) ...device.permissions, ]; @@ -390,7 +376,8 @@ class SmartphoneStudyController { /// wearable that has not been paired yet, say - or when the participant has /// unregistered it. Iterable get _devicesToConnect sync* { - for (final configuration in deployment?.devices ?? []) { + for (final configuration + in deployment?.devices ?? []) { final device = _deviceController.getDeviceManager(configuration.type); debug( '$runtimeType - Checking to connect to device $device with canConnect ' @@ -402,16 +389,12 @@ class SmartphoneStudyController { } } - List? _measurePermissions(Measure measure) { - final dataType = - SamplingPackageRegistry().samplingSchemes[measure.type]?.dataType; - return dataType is CamsDataTypeMetaData ? dataType.permissions : null; - } - /// Configure all devices in this [deployment]. void _configureAllDevices() { assert(deployment != null, 'Deployment is null.'); + addMissingServiceDevices(deployment!); + for (var configuration in deployment!.devices) { _configureDevice(configuration); } @@ -544,3 +527,50 @@ class SmartphoneStudyController { dataManager?.close(); } } + +/// Add the service devices which [deployment]'s measures need, but which it +/// does not declare. +/// +/// Protocols written before API level 3.0 collect data types - step count, +/// audio, phone log - that have since moved to a service of their own, which +/// such a protocol cannot know to declare. Its probes would be created but +/// never get a connected device to sample through. So the sampling package of +/// each measure tells us which device it needs, and any missing one is added +/// here, as if the protocol had declared it. +void addMissingServiceDevices(SmartphoneDeployment deployment) { + final deployedTypes = deployment.devices.map((device) => device.type).toSet(); + + final missingTypes = { + for (final measure in deployment.measures) + for (final package in SamplingPackageRegistry().lookup(measure.type)) + package.deviceType, + }..removeAll(deployedTypes); + + for (final type in missingTypes) { + try { + // Services default their role name, so the type alone describes them. + final configuration = DeviceConfiguration.fromJson({ + Serializable.CLASS_IDENTIFIER: type, + }); + + info( + "Adding device of type '$type' to deployment " + "'${deployment.studyDeploymentId}'. It is needed by a measure in the " + "protocol, but the protocol does not declare it (protocol API level " + "${deployment.protocolApiLevel ?? 'unknown'}).", + ); + + deployment.connectedDevices = { + ...deployment.connectedDevices, + configuration, + }; + } catch (error) { + warning( + "A measure in deployment '${deployment.studyDeploymentId}' needs a " + "device of type '$type', which the protocol does not declare and which " + "could not be created. Data for that measure will not be collected.\n" + "Error: $error", + ); + } + } +} diff --git a/carp_mobile_sensing/lib/sampling_packages.dart b/carp_mobile_sensing/lib/sampling_packages.dart index 14017f293..459a7ca8d 100644 --- a/carp_mobile_sensing/lib/sampling_packages.dart +++ b/carp_mobile_sensing/lib/sampling_packages.dart @@ -37,6 +37,7 @@ import 'package:carp_mobile_sensing/carp_mobile_sensing.dart'; part 'infrastructure/sampling_packages/sensors/sensor_probes.dart'; part 'infrastructure/sampling_packages/sensors/light_probe.dart'; part 'infrastructure/sampling_packages/sensors/pedometer_probe.dart'; +part 'infrastructure/sampling_packages/sensors/activity_service.dart'; part 'infrastructure/sampling_packages/sensors/sensor_data.dart'; part 'infrastructure/sampling_packages/sensors/sensor_package.dart'; diff --git a/carp_mobile_sensing/lib/sampling_packages.g.dart b/carp_mobile_sensing/lib/sampling_packages.g.dart index 6d84c4ffa..c76f95e1f 100644 --- a/carp_mobile_sensing/lib/sampling_packages.g.dart +++ b/carp_mobile_sensing/lib/sampling_packages.g.dart @@ -6,6 +6,27 @@ part of 'sampling_packages.dart'; // JsonSerializableGenerator // ************************************************************************** +ActivityService _$ActivityServiceFromJson(Map json) => + ActivityService(roleName: json['roleName'] as String?) + ..$type = json['__type'] as String? + ..isOptional = json['isOptional'] as bool? + ..defaultSamplingConfiguration = + (json['defaultSamplingConfiguration'] as Map?)?.map( + (k, e) => MapEntry( + k, + SamplingConfiguration.fromJson(e as Map), + ), + ); + +Map _$ActivityServiceToJson(ActivityService instance) => + { + '__type': ?instance.$type, + 'roleName': instance.roleName, + 'isOptional': ?instance.isOptional, + 'defaultSamplingConfiguration': ?instance.defaultSamplingConfiguration + ?.map((k, e) => MapEntry(k, e.toJson())), + }; + AmbientLight _$AmbientLightFromJson(Map json) => AmbientLight( json['meanLux'] as num, diff --git a/carp_mobile_sensing/test/api_level_1_compatibility_test.dart b/carp_mobile_sensing/test/api_level_1_compatibility_test.dart index b2b149ca7..fd07acdd4 100644 --- a/carp_mobile_sensing/test/api_level_1_compatibility_test.dart +++ b/carp_mobile_sensing/test/api_level_1_compatibility_test.dart @@ -129,15 +129,57 @@ void main() { expect(protocol.protocolApiLevel, isNull); }); - test(' - 1.x step count measure type', () { - expect( - SensorSamplingPackage().samplingSchemes.types, - contains(SensorSamplingPackage.STEP_COUNT), + test(' - 1.x step count measure type is still supported', () { + // Step count moved to the ActivitySamplingPackage in API level 3.0 - + // activity recognition is a permission of its own. A 1.x protocol asking + // for it by name must still resolve to a probe. + final packages = SamplingPackageRegistry().lookup( + SensorSamplingPackage.STEP_COUNT, ); + + expect(packages, isNotEmpty); expect( - SensorSamplingPackage().create(SensorSamplingPackage.STEP_COUNT), + packages.first.create(SensorSamplingPackage.STEP_COUNT), isA(), ); }); + + test(' - 1.x protocol gets the service devices its measures need', () { + // A 1.x protocol declares only the phone, and collects step count on it. + // Step count now samples through the ActivityService, which such a + // protocol cannot know to declare - so CAMS adds it, or the probe would + // never get a connected device to sample through. + final protocol = SmartphoneStudyProtocol( + ownerId: 'test', + name: '1.x protocol', + )..addPrimaryDevice(Smartphone()); + + protocol.addTaskControl( + ImmediateTrigger(), + BackgroundTask( + measures: [Measure(type: SensorSamplingPackage.STEP_COUNT)], + ), + protocol.primaryDevice, + Control.Start, + ); + + final deployment = SmartphoneDeployment.fromSmartphoneStudyProtocol( + studyDeploymentId: 'test', + primaryDeviceRoleName: protocol.primaryDevice.roleName, + protocol: protocol, + ); + + expect( + deployment.devices.map((device) => device.type), + isNot(contains(ActivityService.DEVICE_TYPE)), + ); + + addMissingServiceDevices(deployment); + + expect( + deployment.devices.map((device) => device.type), + contains(ActivityService.DEVICE_TYPE), + ); + }); }); } diff --git a/carp_mobile_sensing/test/json/study_deployment.json b/carp_mobile_sensing/test/json/study_deployment.json index 8854ccafe..1083ec41f 100644 --- a/carp_mobile_sensing/test/json/study_deployment.json +++ b/carp_mobile_sensing/test/json/study_deployment.json @@ -1,6 +1,6 @@ { "applicationData": { - "protocolApiLevel": "2.0", + "protocolApiLevel": "3.0", "studyDescription": { "__type": "StudyDescription", "title": "A Test", @@ -34,8 +34,8 @@ }, "registration": { "__type": "dk.cachet.carp.common.application.devices.DefaultDeviceRegistration", - "deviceId": "7ba9d436-39ea-42cf-8487-b8e12d272aa6", - "registrationCreatedOn": "2026-06-11T06:36:21.116452Z" + "deviceId": "6505ec01-f714-441c-9032-23c6067af368", + "registrationCreatedOn": "2026-08-28T14:21:55.870197Z" }, "connectedDevices": [ { @@ -158,15 +158,15 @@ }, { "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.stepevent" + "type": "dk.cachet.carp.ambientlight" }, { "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.stepcount" + "type": "dk.cachet.carp.stepevent" }, { "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.ambientlight" + "type": "dk.cachet.carp.stepcount" }, { "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", @@ -335,6 +335,6 @@ } ], "studyDeploymentId": "1234", - "deployed": "2026-06-11T06:36:21.116445Z", + "deployed": "2026-08-28T14:21:55.870192Z", "status": "Invited" } \ No newline at end of file diff --git a/carp_mobile_sensing/test/json/study_protocol.json b/carp_mobile_sensing/test/json/study_protocol.json index 28ed186f4..94c778475 100644 --- a/carp_mobile_sensing/test/json/study_protocol.json +++ b/carp_mobile_sensing/test/json/study_protocol.json @@ -1,6 +1,6 @@ { "applicationData": { - "protocolApiLevel": "2.0", + "protocolApiLevel": "3.0", "applicationName": "carp_mobile_sensing_example", "studyDescription": { "__type": "StudyDescription", @@ -26,8 +26,8 @@ "uiTheme": "black" } }, - "id": "fff5022a-af09-4a4d-9019-9509d24fc87d", - "createdOn": "2026-06-11T06:36:21.093751Z", + "id": "419474a2-d534-4688-9f65-1cb1224f9dd6", + "createdOn": "2026-08-28T14:21:55.851495Z", "version": 0, "ownerId": "user@dtu.dk", "name": "patient_tracking", @@ -173,15 +173,15 @@ }, { "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.stepevent" + "type": "dk.cachet.carp.ambientlight" }, { "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.stepcount" + "type": "dk.cachet.carp.stepevent" }, { "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", - "type": "dk.cachet.carp.ambientlight" + "type": "dk.cachet.carp.stepcount" }, { "__type": "dk.cachet.carp.common.application.tasks.Measure.DataStream", diff --git a/carp_mobile_sensing/test/permissions_test.dart b/carp_mobile_sensing/test/permissions_test.dart index 727cbc95d..ff99a2fa5 100644 --- a/carp_mobile_sensing/test/permissions_test.dart +++ b/carp_mobile_sensing/test/permissions_test.dart @@ -252,4 +252,25 @@ void main() { expect(await device.hasPermissions(), isTrue); expect(await device.connect(), DeviceStatus.connected); }); + + test("notifications are asked for - that permission is the app's, not a " + "device's", () async { + final calls = fakePermissionHandler(); + + // Every other permission moved to the device that needs it. Notification + // is the app's own - no device declares it - so the client manager asks + // for it when configuring, which is what this stands in for. Android 13+ + // shows no notification at all until something does. + expect( + SamplingPackageRegistry().packages + .expand((package) => package.deviceManager.permissions), + isNot(contains(Permission.notification)), + ); + + await SmartPhoneClientManager().requestPermissions([ + Permission.notification, + ]); + + expect(calls, contains('request(${Permission.notification.value})')); + }); } diff --git a/packages/carp_audio_package/lib/media_services.dart b/packages/carp_audio_package/lib/media_services.dart new file mode 100644 index 000000000..08203486e --- /dev/null +++ b/packages/carp_audio_package/lib/media_services.dart @@ -0,0 +1,90 @@ +part of 'media.dart'; + +/// A [ServiceConfiguration] for the phone's microphone. +/// +/// Add it to a protocol - `addConnectedDevice(MicrophoneService(), phone)` - +/// to record audio or noise. The microphone is a permission of its own, so it +/// is a service of its own: a study that does not deploy it can never ask for it. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class MicrophoneService extends ServiceConfiguration { + static const String DEVICE_TYPE = + '${CamsDevice.CAMS_DEVICE_NAMESPACE}.MicrophoneService'; + static const String DEFAULT_ROLE_NAME = 'Microphone Service'; + + MicrophoneService({String? roleName}) + : super(roleName: roleName ?? DEFAULT_ROLE_NAME); + + @override + Function get fromJsonFunction => _$MicrophoneServiceFromJson; + factory MicrophoneService.fromJson(Map json) => + FromJsonFactory().fromJson(json); + @override + Map toJson() => _$MicrophoneServiceToJson(this); +} + +/// A [ServiceConfiguration] for the phone's camera. +/// +/// Add it to a protocol - `addConnectedDevice(CameraService(), phone)` - to +/// capture images or video. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class CameraService extends ServiceConfiguration { + static const String DEVICE_TYPE = + '${CamsDevice.CAMS_DEVICE_NAMESPACE}.CameraService'; + static const String DEFAULT_ROLE_NAME = 'Camera Service'; + + CameraService({String? roleName}) + : super(roleName: roleName ?? DEFAULT_ROLE_NAME); + + @override + Function get fromJsonFunction => _$CameraServiceFromJson; + factory CameraService.fromJson(Map json) => + FromJsonFactory().fromJson(json); + @override + Map toJson() => _$CameraServiceToJson(this); +} + +/// A [DeviceManager] for a media capture service on the phone. +abstract class MediaServiceManager + extends ServiceManager { + MediaServiceManager(super.deviceType, {super.configuration}); + + @override + ServiceRegistration createRegistration() => + ServiceRegistration(deviceDisplayName: displayName); + + @override + bool get canConnect => true; + + @override + void onConfigure() {} + + @override + Future onConnect() async => DeviceStatus.connected; + + @override + Future onDisconnect() async => true; +} + +/// A [DeviceManager] for the phone's microphone. +class MicrophoneServiceManager extends MediaServiceManager { + MicrophoneServiceManager([MicrophoneService? configuration]) + : super(MicrophoneService.DEVICE_TYPE, configuration: configuration); + + @override + String? get displayName => 'Microphone'; + + @override + List get permissions => [Permission.microphone]; +} + +/// A [DeviceManager] for the phone's camera. +class CameraServiceManager extends MediaServiceManager { + CameraServiceManager([CameraService? configuration]) + : super(CameraService.DEVICE_TYPE, configuration: configuration); + + @override + String? get displayName => 'Camera'; + + @override + List get permissions => [Permission.camera]; +} diff --git a/packages/carp_communication_package/lib/communication_services.dart b/packages/carp_communication_package/lib/communication_services.dart new file mode 100644 index 000000000..198ab70a3 --- /dev/null +++ b/packages/carp_communication_package/lib/communication_services.dart @@ -0,0 +1,129 @@ +part of 'communication.dart'; + +/// A [ServiceConfiguration] for the phone's call log. +/// +/// Add it to a protocol - `addConnectedDevice(PhoneLogService(), phone)` - to +/// collect the phone log. Call log is a Play Store sensitive permission, so it +/// is a service of its own: a study that does not deploy it can never ask for it. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class PhoneLogService extends ServiceConfiguration { + static const String DEVICE_TYPE = + '${CamsDevice.CAMS_DEVICE_NAMESPACE}.PhoneLogService'; + static const String DEFAULT_ROLE_NAME = 'Phone Log Service'; + + PhoneLogService({String? roleName}) + : super(roleName: roleName ?? DEFAULT_ROLE_NAME); + + @override + Function get fromJsonFunction => _$PhoneLogServiceFromJson; + factory PhoneLogService.fromJson(Map json) => + FromJsonFactory().fromJson(json); + @override + Map toJson() => _$PhoneLogServiceToJson(this); +} + +/// A [ServiceConfiguration] for the phone's text messages (SMS). +/// +/// Add it to a protocol - `addConnectedDevice(TextMessageService(), phone)` - +/// to collect text messages. SMS is a Play Store sensitive permission, so it is +/// a service of its own. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class TextMessageService extends ServiceConfiguration { + static const String DEVICE_TYPE = + '${CamsDevice.CAMS_DEVICE_NAMESPACE}.TextMessageService'; + static const String DEFAULT_ROLE_NAME = 'Text Message Service'; + + TextMessageService({String? roleName}) + : super(roleName: roleName ?? DEFAULT_ROLE_NAME); + + @override + Function get fromJsonFunction => _$TextMessageServiceFromJson; + factory TextMessageService.fromJson(Map json) => + FromJsonFactory().fromJson(json); + @override + Map toJson() => _$TextMessageServiceToJson(this); +} + +/// A [ServiceConfiguration] for the phone's calendar. +/// +/// Add it to a protocol - `addConnectedDevice(CalendarService(), phone)` - to +/// collect calendar entries. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class CalendarService extends ServiceConfiguration { + static const String DEVICE_TYPE = + '${CamsDevice.CAMS_DEVICE_NAMESPACE}.CalendarService'; + static const String DEFAULT_ROLE_NAME = 'Calendar Service'; + + CalendarService({String? roleName}) + : super(roleName: roleName ?? DEFAULT_ROLE_NAME); + + @override + Function get fromJsonFunction => _$CalendarServiceFromJson; + factory CalendarService.fromJson(Map json) => + FromJsonFactory().fromJson(json); + @override + Map toJson() => _$CalendarServiceToJson(this); +} + +/// A [DeviceManager] for a communication service on the phone. +abstract class CommunicationServiceManager< + TConfiguration extends ServiceConfiguration +> + extends ServiceManager { + CommunicationServiceManager(super.deviceType, {super.configuration}); + + @override + ServiceRegistration createRegistration() => + ServiceRegistration(deviceDisplayName: displayName); + + @override + bool get canConnect => true; + + @override + void onConfigure() {} + + @override + Future onConnect() async => DeviceStatus.connected; + + @override + Future onDisconnect() async => true; +} + +/// A [DeviceManager] for the phone's call log. +class PhoneLogServiceManager + extends CommunicationServiceManager { + PhoneLogServiceManager([PhoneLogService? configuration]) + : super(PhoneLogService.DEVICE_TYPE, configuration: configuration); + + @override + String? get displayName => 'Phone Log'; + + @override + List get permissions => [Permission.phone]; +} + +/// A [DeviceManager] for the phone's text messages. +class TextMessageServiceManager + extends CommunicationServiceManager { + TextMessageServiceManager([TextMessageService? configuration]) + : super(TextMessageService.DEVICE_TYPE, configuration: configuration); + + @override + String? get displayName => 'Text Messages'; + + @override + List get permissions => [Permission.sms]; +} + +/// A [DeviceManager] for the phone's calendar. +class CalendarServiceManager + extends CommunicationServiceManager { + CalendarServiceManager([CalendarService? configuration]) + : super(CalendarService.DEVICE_TYPE, configuration: configuration); + + @override + String? get displayName => 'Calendar'; + + @override + List get permissions => [Permission.calendarFullAccess]; +} diff --git a/packages/carp_connectivity_package/lib/connectivity_service.dart b/packages/carp_connectivity_package/lib/connectivity_service.dart new file mode 100644 index 000000000..f50570a4d --- /dev/null +++ b/packages/carp_connectivity_package/lib/connectivity_service.dart @@ -0,0 +1,99 @@ +part of '../connectivity.dart'; + +/// A [ServiceConfiguration] for scanning nearby Bluetooth devices and beacons. +/// +/// Add it to a protocol - `addConnectedDevice(BluetoothScanService(), phone)` - +/// to collect Bluetooth or beacon data. Scanning is a permission of its own, so +/// it is a service of its own: a study that does not deploy it can never ask +/// for it. +@JsonSerializable(includeIfNull: false, explicitToJson: true) +class BluetoothScanService extends ServiceConfiguration { + static const String DEVICE_TYPE = + '${CamsDevice.CAMS_DEVICE_NAMESPACE}.BluetoothScanService'; + static const String DEFAULT_ROLE_NAME = 'Bluetooth Scan Service'; + + BluetoothScanService({String? roleName}) + : super(roleName: roleName ?? DEFAULT_ROLE_NAME); + + @override + Function get fromJsonFunction => _$BluetoothScanServiceFromJson; + factory BluetoothScanService.fromJson(Map json) => + FromJsonFactory().fromJson(json); + @override + Map toJson() => _$BluetoothScanServiceToJson(this); +} + +/// A [DeviceManager] for scanning nearby Bluetooth devices and beacons. +class BluetoothScanServiceManager + extends ServiceManager { + BluetoothScanServiceManager([BluetoothScanService? configuration]) + : super(BluetoothScanService.DEVICE_TYPE, configuration: configuration); + + @override + String? get displayName => 'Bluetooth Scan'; + + /// Beacon ranging in the background needs location - scanning alone does not, + /// but Android only reports beacons when location is available too. + @override + List get permissions => Platform.isAndroid + ? [Permission.bluetoothScan, Permission.locationAlways] + : [Permission.bluetooth]; + + @override + ServiceRegistration createRegistration() => + ServiceRegistration(deviceDisplayName: displayName); + + @override + bool get canConnect => true; + + @override + void onConfigure() {} + + @override + Future onConnect() async => DeviceStatus.connected; + + @override + Future onDisconnect() async => true; +} + +/// The sampling package for scanning nearby Bluetooth devices and beacons. +class BluetoothScanSamplingPackage extends SmartphoneSamplingPackage { + final _deviceManager = BluetoothScanServiceManager(); + + @override + String get deviceType => BluetoothScanService.DEVICE_TYPE; + + @override + DeviceManager get deviceManager => _deviceManager; + + @override + DataTypeSamplingSchemeMap get samplingSchemes => + DataTypeSamplingSchemeMap.from([ + DataTypeSamplingScheme( + CamsDataTypeMetaData( + type: ConnectivitySamplingPackage.BLUETOOTH, + displayName: "Bluetooth Scan of Nearby Devices", + timeType: DataTimeType.TIME_SPAN, + ), + PeriodicSamplingConfiguration( + interval: const Duration(minutes: 10), + duration: const Duration(seconds: 10), + ), + ), + DataTypeSamplingScheme( + CamsDataTypeMetaData( + type: ConnectivitySamplingPackage.BEACON, + displayName: "Ranging iBeacons", + timeType: DataTimeType.POINT, + ), + BeaconRangingPeriodicSamplingConfiguration(), + ), + ]); + + @override + Probe? create(String type) => switch (type) { + ConnectivitySamplingPackage.BLUETOOTH => BluetoothProbe(), + ConnectivitySamplingPackage.BEACON => BeaconProbe(), + _ => null, + }; +}