From f49aff6ea57545d01e3ec637b0771a456b655a36 Mon Sep 17 00:00:00 2001 From: Grzegorz Miszewski Date: Mon, 22 Jun 2026 10:44:40 +0200 Subject: [PATCH] feat: expose deviceProfile, displayName and forceConfirmation Grow CdmRequest and the native builder to cover the scalar AssociationRequest.Builder options. All new fields are optional and default to today's behaviour. - CdmDeviceProfile enum -> AssociationRequest.DEVICE_PROFILE_* (watch 31; computer/appStreaming/automotiveProjection 33; glasses/ nearbyDeviceStreaming 34). Native rejects a profile the device's API level can't support with CdmErrorCode.unsupported, up-front. - displayName -> setDisplayName, forceConfirmation -> setForceConfirmation (both API 33+, best-effort below). Builder setters guarded per Build.VERSION. - Tests: serialisation of the new fields and defaults, equality, and the profile wire/minSdk table. - README: device-profile + dialog-option docs, a Limitations note on the picker icon, and a corrected API list. CHANGELOG 0.0.2. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 ++++ README.md | 45 ++++++++++++-- .../FlutterAndroidCdmPlugin.kt | 61 ++++++++++++++++++- lib/flutter_android_cdm.dart | 1 + lib/src/cdm_device_profile.dart | 43 +++++++++++++ lib/src/cdm_request.dart | 42 ++++++++++++- pubspec.yaml | 2 +- test/cdm_request_test.dart | 41 +++++++++++++ ...utter_android_cdm_method_channel_test.dart | 21 +++++++ 9 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 lib/src/cdm_device_profile.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b9cd033..ca0ce1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 0.0.2 + +* `CdmRequest` gains optional `deviceProfile`, `displayName`, and + `forceConfirmation`, mapping to `AssociationRequest.Builder`. +* New `CdmDeviceProfile` enum (`watch`, `computer`, `appStreaming`, + `automotiveProjection`, `glasses`, `nearbyDeviceStreaming`); requesting a + profile the device's API level can't support throws + `CdmErrorCode.unsupported`. +* `displayName` / `forceConfirmation` apply on API 33+; older devices ignore + them. Defaults preserve the previous behaviour. + ## 0.0.1 * Initial release. diff --git a/README.md b/README.md index b962f43..237a176 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ both side-by-side for cross-platform OS-managed pairing. ```yaml dependencies: - flutter_android_cdm: ^0.0.1 + flutter_android_cdm: ^0.0.2 ``` ## Usage @@ -47,6 +47,37 @@ try { } ``` +## Device profiles & dialog options + +`CdmRequest` exposes the optional parts of `AssociationRequest.Builder`: + +```dart +await cdm.associate(const CdmRequest( + namePattern: r'^MyWatch-', + deviceProfile: CdmDeviceProfile.watch, // changes dialog copy + capabilities + displayName: 'My Watch', // name the system shows (API 33+) + forceConfirmation: true, // always show the chooser (API 33+) +)); +``` + +- **`deviceProfile`** maps to `AssociationRequest.DEVICE_PROFILE_*`. A profile is + not cosmetic: it changes the system dialog copy, grants that profile's + capabilities, and may require the app to hold a specific permission. Profiles + are version-gated (`watch` API 31; `computer`/`appStreaming`/ + `automotiveProjection` API 33; `glasses`/`nearbyDeviceStreaming` API 34). + Requesting one the device can't support throws `CdmErrorCode.unsupported`. +- **`displayName`** / **`forceConfirmation`** require API 33+ and are silently + ignored on older devices. + +## Limitations + +You **cannot** put a custom icon (or a Flutter asset) into the standard CDM +picker — the dialog is rendered by the system. Its icon, layout, and body copy +are not app-customizable; the body text is a function of `deviceProfile` only. +A custom icon is possible only in the *self-managed* confirmation dialog +(`setDeviceIcon`, API 36.1+), which forfeits CDM's system scanning and is not +exposed by this plugin. + ## App-side setup This plugin contributes `` @@ -73,11 +104,17 @@ defaultConfig { ## API - `FlutterAndroidCdm.associate(CdmRequest)` → `Future` -- `CdmRequest({serviceUuid, namePattern, singleDevice})` -- `AssociatedDevice(address, name)` +- `FlutterAndroidCdm.getAssociations()` → `Future>` +- `FlutterAndroidCdm.disassociate(AssociatedDevice)` → `Future` +- `CdmRequest({serviceUuid, namePattern, singleDevice, deviceProfile, + displayName, forceConfirmation})` +- `CdmDeviceProfile` — `watch`, `computer`, `appStreaming`, + `automotiveProjection`, `glasses`, `nearbyDeviceStreaming` +- `AssociatedDevice(address, name, id)` - `CdmException(code, message)` with codes: `cancelled`, `busy`, `unsupported`, `cdm_failure`, `launch_failed`, - `no_device`, `no_activity`, `bad_args` + `no_device`, `no_activity`, `bad_args`, `engine_detached`, + `activity_detached`, `disassociate_failed`, `get_associations_failed` ## Example diff --git a/android/src/main/kotlin/com/withintent/flutter_android_cdm/FlutterAndroidCdmPlugin.kt b/android/src/main/kotlin/com/withintent/flutter_android_cdm/FlutterAndroidCdmPlugin.kt index 804767b..fe153e0 100644 --- a/android/src/main/kotlin/com/withintent/flutter_android_cdm/FlutterAndroidCdmPlugin.kt +++ b/android/src/main/kotlin/com/withintent/flutter_android_cdm/FlutterAndroidCdmPlugin.kt @@ -92,6 +92,8 @@ class FlutterAndroidCdmPlugin : val serviceUuid = call.argument("serviceUuid") val namePattern = call.argument("namePattern") val singleDevice = call.argument("singleDevice") ?: false + val displayName = call.argument("displayName") + val forceConfirmation = call.argument("forceConfirmation") ?: false if (serviceUuid == null && namePattern == null) { return result.error( @@ -101,20 +103,60 @@ class FlutterAndroidCdmPlugin : ) } + // Resolve the device profile (if any) and reject it up-front when the + // device's API level is too low — the profile changes permissions and + // dialog copy, so failing silently would mislead the caller. + val profileToken = call.argument("deviceProfile") + var deviceProfile: String? = null + if (profileToken != null) { + val spec = resolveProfile(profileToken) + ?: return result.error("bad_args", "Unknown device profile: $profileToken", null) + if (Build.VERSION.SDK_INT < spec.minSdk) { + return result.error( + "unsupported", + "Device profile '$profileToken' requires API ${spec.minSdk} " + + "(device is API ${Build.VERSION.SDK_INT}).", + null, + ) + } + deviceProfile = spec.value + } + pendingResult = result try { - associateInternal(act, serviceUuid, namePattern, singleDevice) + associateInternal( + act, serviceUuid, namePattern, singleDevice, + deviceProfile, displayName, forceConfirmation, + ) } catch (t: Throwable) { finishWith { it.error("cdm_failure", t.message ?: t.javaClass.simpleName, null) } } } + /** Maps a Dart profile token to its `DEVICE_PROFILE_*` value + min API. */ + private fun resolveProfile(token: String): ProfileSpec? = when (token) { + "watch" -> ProfileSpec(AssociationRequest.DEVICE_PROFILE_WATCH, 31) + "computer" -> ProfileSpec(AssociationRequest.DEVICE_PROFILE_COMPUTER, 33) + "app_streaming" -> ProfileSpec(AssociationRequest.DEVICE_PROFILE_APP_STREAMING, 33) + "automotive_projection" -> + ProfileSpec(AssociationRequest.DEVICE_PROFILE_AUTOMOTIVE_PROJECTION, 33) + "glasses" -> ProfileSpec(AssociationRequest.DEVICE_PROFILE_GLASSES, 34) + "nearby_device_streaming" -> + ProfileSpec(AssociationRequest.DEVICE_PROFILE_NEARBY_DEVICE_STREAMING, 34) + else -> null + } + + private data class ProfileSpec(val value: String, val minSdk: Int) + @RequiresApi(Build.VERSION_CODES.O) private fun associateInternal( activity: Activity, serviceUuid: String?, namePattern: String?, singleDevice: Boolean, + deviceProfile: String?, + displayName: String?, + forceConfirmation: Boolean, ) { val deviceFilter = BluetoothLeDeviceFilter.Builder().apply { if (serviceUuid != null) { @@ -128,10 +170,23 @@ class FlutterAndroidCdmPlugin : } }.build() - val request = AssociationRequest.Builder() + val builder = AssociationRequest.Builder() .addDeviceFilter(deviceFilter) .setSingleDevice(singleDevice) - .build() + + // setDeviceProfile is API 31+; the per-profile minSdk check in + // handleAssociate already guarantees SDK_INT >= 31 when set. + if (deviceProfile != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setDeviceProfile(deviceProfile) + } + // setDisplayName / setForceConfirmation are API 33+; best-effort below. + if (displayName != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + builder.setDisplayName(displayName) + } + if (forceConfirmation && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + builder.setForceConfirmation(true) + } + val request = builder.build() val cdm = activity.getSystemService(CompanionDeviceManager::class.java) ?: throw IllegalStateException("CompanionDeviceManager is not available on this device") diff --git a/lib/flutter_android_cdm.dart b/lib/flutter_android_cdm.dart index f0867b6..990e24d 100644 --- a/lib/flutter_android_cdm.dart +++ b/lib/flutter_android_cdm.dart @@ -15,6 +15,7 @@ import 'src/cdm_request.dart'; import 'flutter_android_cdm_platform_interface.dart'; export 'src/associated_device.dart'; +export 'src/cdm_device_profile.dart'; export 'src/cdm_request.dart'; export 'src/cdm_exception.dart'; diff --git a/lib/src/cdm_device_profile.dart b/lib/src/cdm_device_profile.dart new file mode 100644 index 0000000..21d03fc --- /dev/null +++ b/lib/src/cdm_device_profile.dart @@ -0,0 +1,43 @@ +/// A CDM device profile, mapping to `AssociationRequest.DEVICE_PROFILE_*`. +/// +/// Setting a profile is **not** cosmetic. Each profile: +/// - changes the copy the system shows in the picker/confirmation dialog, +/// - grants the app that profile's capabilities once associated, and +/// - may require the app to hold a specific permission and to fulfil the +/// profile's post-association obligations. +/// +/// Profiles are also version-gated. The [minSdk] of the chosen profile must be +/// met by the device, otherwise [FlutterAndroidCdm.associate] throws a +/// [CdmException] with [CdmErrorCode.unsupported]. Leave the profile unset +/// (`null`) for the generic BLE picker, which works from API 26. +enum CdmDeviceProfile { + /// `DEVICE_PROFILE_WATCH` — a watch. Added in API 31. + watch('watch', 31), + + /// `DEVICE_PROFILE_COMPUTER` — a nearby computer. Added in API 33. + computer('computer', 33), + + /// `DEVICE_PROFILE_APP_STREAMING` — streaming apps to another device. + /// Added in API 33. + appStreaming('app_streaming', 33), + + /// `DEVICE_PROFILE_AUTOMOTIVE_PROJECTION` — projecting to a car head unit. + /// Added in API 33. + automotiveProjection('automotive_projection', 33), + + /// `DEVICE_PROFILE_GLASSES` — smart glasses. Added in API 34. + glasses('glasses', 34), + + /// `DEVICE_PROFILE_NEARBY_DEVICE_STREAMING` — streaming to a nearby device. + /// Added in API 34. + nearbyDeviceStreaming('nearby_device_streaming', 34); + + const CdmDeviceProfile(this.wire, this.minSdk); + + /// The token sent over the platform channel; the native side maps it to the + /// matching `AssociationRequest.DEVICE_PROFILE_*` constant. + final String wire; + + /// The minimum Android API level that supports this profile. + final int minSdk; +} diff --git a/lib/src/cdm_request.dart b/lib/src/cdm_request.dart index 1a62093..71e303e 100644 --- a/lib/src/cdm_request.dart +++ b/lib/src/cdm_request.dart @@ -1,5 +1,7 @@ import 'package:meta/meta.dart'; +import 'cdm_device_profile.dart'; + /// Filter criteria for the CDM picker. /// /// At least one of [serviceUuid] or [namePattern] must be set, otherwise the @@ -10,6 +12,9 @@ class CdmRequest { this.serviceUuid, this.namePattern, this.singleDevice = false, + this.deviceProfile, + this.displayName, + this.forceConfirmation = false, }) : assert( serviceUuid != null || namePattern != null, 'Provide at least one filter (serviceUuid or namePattern).', @@ -26,16 +31,46 @@ class CdmRequest { /// found. When `false`, the user always sees the chooser. final bool singleDevice; + /// The CDM device profile to request, or `null` for the generic picker. + /// + /// See [CdmDeviceProfile] for the implications. Requesting a profile the + /// device's API level doesn't support throws [CdmErrorCode.unsupported]. + final CdmDeviceProfile? deviceProfile; + + /// The name the system shows for the device in the dialog. + /// + /// Forwarded to `AssociationRequest.Builder.setDisplayName` (**API 33+**). + /// Silently ignored on older devices, which fall back to the advertised + /// BLE name. + final String? displayName; + + /// When `true`, always show the chooser even if [singleDevice] would + /// otherwise auto-select a lone match. + /// + /// Forwarded to `AssociationRequest.Builder.setForceConfirmation` + /// (**API 33+**). Silently ignored on older devices. + final bool forceConfirmation; + @override bool operator ==(Object other) => identical(this, other) || other is CdmRequest && other.serviceUuid == serviceUuid && other.namePattern == namePattern && - other.singleDevice == singleDevice; + other.singleDevice == singleDevice && + other.deviceProfile == deviceProfile && + other.displayName == displayName && + other.forceConfirmation == forceConfirmation; @override - int get hashCode => Object.hash(serviceUuid, namePattern, singleDevice); + int get hashCode => Object.hash( + serviceUuid, + namePattern, + singleDevice, + deviceProfile, + displayName, + forceConfirmation, + ); /// Internal — serialises to the platform channel. @internal @@ -43,5 +78,8 @@ class CdmRequest { 'serviceUuid': serviceUuid, 'namePattern': namePattern, 'singleDevice': singleDevice, + 'deviceProfile': deviceProfile?.wire, + 'displayName': displayName, + 'forceConfirmation': forceConfirmation, }; } diff --git a/pubspec.yaml b/pubspec.yaml index 3fe0270..77f321e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ description: >- the system-rendered accessory picker — filtered by Service UUID, name pattern, or raw advertising data — without rolling your own scan UI or requesting runtime BLUETOOTH_SCAN / location permissions. -version: 0.0.1 +version: 0.0.2 homepage: https://github.com/dotintent/flutter-android-cdm repository: https://github.com/dotintent/flutter-android-cdm issue_tracker: https://github.com/dotintent/flutter-android-cdm/issues diff --git a/test/cdm_request_test.dart b/test/cdm_request_test.dart index 402e6a4..3a23c0b 100644 --- a/test/cdm_request_test.dart +++ b/test/cdm_request_test.dart @@ -21,11 +21,17 @@ void main() { serviceUuid: 'uuid', namePattern: 'pat', singleDevice: true, + deviceProfile: CdmDeviceProfile.computer, + displayName: 'name', + forceConfirmation: true, ); expect(r.toMap(), { 'serviceUuid': 'uuid', 'namePattern': 'pat', 'singleDevice': true, + 'deviceProfile': 'computer', + 'displayName': 'name', + 'forceConfirmation': true, }); }); @@ -36,8 +42,43 @@ void main() { 'serviceUuid': null, 'namePattern': 'pat', 'singleDevice': false, + 'deviceProfile': null, + 'displayName': null, + 'forceConfirmation': false, }); }); + + test('equality accounts for the new fields', () { + const base = CdmRequest(namePattern: 'pat'); + expect( + base, + isNot(equals(const CdmRequest( + namePattern: 'pat', + deviceProfile: CdmDeviceProfile.watch, + ))), + ); + expect( + base, + isNot(equals(const CdmRequest(namePattern: 'pat', displayName: 'x'))), + ); + expect( + base, + isNot(equals( + const CdmRequest(namePattern: 'pat', forceConfirmation: true), + )), + ); + }); + }); + + group('CdmDeviceProfile', () { + test('wire tokens and min SDK levels are stable', () { + expect(CdmDeviceProfile.watch.wire, 'watch'); + expect(CdmDeviceProfile.watch.minSdk, 31); + expect(CdmDeviceProfile.glasses.wire, 'glasses'); + expect(CdmDeviceProfile.glasses.minSdk, 34); + final tokens = CdmDeviceProfile.values.map((p) => p.wire).toList(); + expect(tokens.toSet(), hasLength(tokens.length)); + }); }); group('AssociatedDevice', () { diff --git a/test/flutter_android_cdm_method_channel_test.dart b/test/flutter_android_cdm_method_channel_test.dart index 1287466..5dc4c39 100644 --- a/test/flutter_android_cdm_method_channel_test.dart +++ b/test/flutter_android_cdm_method_channel_test.dart @@ -41,6 +41,9 @@ void main() { serviceUuid: '0000180d-0000-1000-8000-00805f9b34fb', namePattern: 'Sensor.*', singleDevice: true, + deviceProfile: CdmDeviceProfile.watch, + displayName: 'My Watch', + forceConfirmation: true, )); expect(lastCall?.method, 'associate'); @@ -48,6 +51,24 @@ void main() { 'serviceUuid': '0000180d-0000-1000-8000-00805f9b34fb', 'namePattern': 'Sensor.*', 'singleDevice': true, + 'deviceProfile': 'watch', + 'displayName': 'My Watch', + 'forceConfirmation': true, + }); + }); + + test('serialises defaults for the optional builder fields', () async { + handler = (_) => {'address': 'AA'}; + + await platform.associate(const CdmRequest(namePattern: 'Sensor.*')); + + expect(lastCall?.arguments, { + 'serviceUuid': null, + 'namePattern': 'Sensor.*', + 'singleDevice': false, + 'deviceProfile': null, + 'displayName': null, + 'forceConfirmation': false, }); });