Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
45 changes: 41 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<uses-feature android:name="android.software.companion_device_setup"/>`
Expand All @@ -73,11 +104,17 @@ defaultConfig {
## API

- `FlutterAndroidCdm.associate(CdmRequest)` → `Future<AssociatedDevice>`
- `CdmRequest({serviceUuid, namePattern, singleDevice})`
- `AssociatedDevice(address, name)`
- `FlutterAndroidCdm.getAssociations()` → `Future<List<AssociatedDevice>>`
- `FlutterAndroidCdm.disassociate(AssociatedDevice)` → `Future<void>`
- `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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ class FlutterAndroidCdmPlugin :
val serviceUuid = call.argument<String>("serviceUuid")
val namePattern = call.argument<String>("namePattern")
val singleDevice = call.argument<Boolean>("singleDevice") ?: false
val displayName = call.argument<String>("displayName")
val forceConfirmation = call.argument<Boolean>("forceConfirmation") ?: false

if (serviceUuid == null && namePattern == null) {
return result.error(
Expand All @@ -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<String>("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) {
Expand All @@ -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")
Expand Down
1 change: 1 addition & 0 deletions lib/flutter_android_cdm.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
43 changes: 43 additions & 0 deletions lib/src/cdm_device_profile.dart
Original file line number Diff line number Diff line change
@@ -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;
}
42 changes: 40 additions & 2 deletions lib/src/cdm_request.dart
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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).',
Expand All @@ -26,22 +31,55 @@ 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
Map<String, Object?> toMap() => <String, Object?>{
'serviceUuid': serviceUuid,
'namePattern': namePattern,
'singleDevice': singleDevice,
'deviceProfile': deviceProfile?.wire,
'displayName': displayName,
'forceConfirmation': forceConfirmation,
};
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions test/cdm_request_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ void main() {
serviceUuid: 'uuid',
namePattern: 'pat',
singleDevice: true,
deviceProfile: CdmDeviceProfile.computer,
displayName: 'name',
forceConfirmation: true,
);
expect(r.toMap(), <String, Object?>{
'serviceUuid': 'uuid',
'namePattern': 'pat',
'singleDevice': true,
'deviceProfile': 'computer',
'displayName': 'name',
'forceConfirmation': true,
});
});

Expand All @@ -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', () {
Expand Down
Loading