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,
});
});