diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..6871fe9 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,4 @@ +{ + "flutter": "3.44.2", + "flavors": {} +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e137b2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# Cancel superseded runs for the same ref. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Resolves the Flutter version from .fvmrc once and exposes it to the build + # jobs, so the toolchain is driven by fvm — never hardcoded in the workflow. + analyze-and-test: + name: Format · Analyze · Test + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + flutter-version: ${{ steps.fvm.outputs.FLUTTER_VERSION }} + flutter-channel: ${{ steps.fvm.outputs.FLUTTER_CHANNEL }} + steps: + - uses: actions/checkout@v7 + + - id: fvm + name: Read Flutter version from .fvmrc + uses: kuhnroyal/flutter-fvm-config-action@v3 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ steps.fvm.outputs.FLUTTER_VERSION }} + channel: ${{ steps.fvm.outputs.FLUTTER_CHANNEL }} + cache: true + + - run: flutter pub get + - name: Check formatting + run: dart format --output=none --set-exit-if-changed lib test + - name: Analyze + run: flutter analyze + - name: Test + run: flutter test + + build-example: + name: Build example APK + needs: analyze-and-test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ needs.analyze-and-test.outputs.flutter-version }} + channel: ${{ needs.analyze-and-test.outputs.flutter-channel }} + cache: true + + - run: flutter pub get + working-directory: example + - run: flutter build apk --debug + working-directory: example + + pana: + name: pana score gate + needs: analyze-and-test + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ needs.analyze-and-test.outputs.flutter-version }} + channel: ${{ needs.analyze-and-test.outputs.flutter-channel }} + cache: true + + - run: flutter pub get + - name: Run pana + run: dart pub global activate pana && dart pub global run pana --no-warning --json . > pana.json + - name: Enforce score threshold + # Baseline gate. Raise toward the 150/160 target as the package matures. + env: + THRESHOLD: "120" + run: | + python3 - <<'PY' + import json, os, sys + data = json.load(open("pana.json")) + granted = data["scores"]["grantedPoints"] + maximum = data["scores"]["maxPoints"] + threshold = int(os.environ["THRESHOLD"]) + print(f"pana score: {granted}/{maximum} (threshold {threshold})") + if granted < threshold: + print(f"::error::pana score {granted} is below the threshold of {threshold}") + sys.exit(1) + PY diff --git a/.gitignore b/.gitignore index b9d7f25..09ef917 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,9 @@ migrate_working_dir/ .flutter-plugins-dependencies /build/ /coverage/ + +# FVM Version Cache +.fvm/ + +# Internal roadmap — kept locally, never published. +IMPROVEMENT_PLAN.md diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index 7abe4cb..0000000 --- a/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'flutter_android_cdm' diff --git a/android/src/test/kotlin/com/withintent/flutter_android_cdm/FlutterAndroidCdmPluginTest.kt b/android/src/test/kotlin/com/withintent/flutter_android_cdm/FlutterAndroidCdmPluginTest.kt deleted file mode 100644 index a512da4..0000000 --- a/android/src/test/kotlin/com/withintent/flutter_android_cdm/FlutterAndroidCdmPluginTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.withintent.flutter_android_cdm - -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import org.mockito.Mockito -import kotlin.test.Test - -/* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. - * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. - */ - -internal class FlutterAndroidCdmPluginTest { - @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = FlutterAndroidCdmPlugin() - - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) - - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) - } -} diff --git a/example/pubspec.lock b/example/pubspec.lock index e208372..d3291a0 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -162,10 +162,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" path: dependency: transitive description: @@ -194,10 +194,10 @@ packages: dependency: transitive description: name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" url: "https://pub.dev" source: hosted - version: "9.4.7" + version: "9.4.10" permission_handler_html: dependency: transitive description: @@ -303,10 +303,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" vector_math: dependency: transitive description: @@ -340,5 +340,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.24.0" diff --git a/lib/flutter_android_cdm.dart b/lib/flutter_android_cdm.dart index 66ce641..f0867b6 100644 --- a/lib/flutter_android_cdm.dart +++ b/lib/flutter_android_cdm.dart @@ -24,8 +24,7 @@ class FlutterAndroidCdm { /// Shared singleton instance. static final FlutterAndroidCdm instance = FlutterAndroidCdm._(); - FlutterAndroidCdmPlatform get _platform => - FlutterAndroidCdmPlatform.instance; + FlutterAndroidCdmPlatform get _platform => FlutterAndroidCdmPlatform.instance; /// Shows the CDM system picker filtered by [request] and resolves to the /// device the user selects. diff --git a/lib/src/cdm_exception.dart b/lib/src/cdm_exception.dart index d00089c..f570336 100644 --- a/lib/src/cdm_exception.dart +++ b/lib/src/cdm_exception.dart @@ -60,7 +60,8 @@ class CdmException implements Exception { CdmException(this.code, this.message, {this.details}); /// Convenience constructor that resolves the wire string to the enum. - factory CdmException.fromWire(String wire, String message, {Object? details}) => + factory CdmException.fromWire(String wire, String message, + {Object? details}) => CdmException(CdmErrorCode.fromWire(wire), message, details: details); /// Type-safe error code. diff --git a/pubspec.yaml b/pubspec.yaml index 28e7491..3fe0270 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,9 +6,9 @@ description: >- pattern, or raw advertising data — without rolling your own scan UI or requesting runtime BLUETOOTH_SCAN / location permissions. version: 0.0.1 -homepage: https://github.com/withintent/flutter_android_cdm -repository: https://github.com/withintent/flutter_android_cdm -issue_tracker: https://github.com/withintent/flutter_android_cdm/issues +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 topics: - bluetooth - ble diff --git a/test/cdm_request_test.dart b/test/cdm_request_test.dart index 4b1b9f6..402e6a4 100644 --- a/test/cdm_request_test.dart +++ b/test/cdm_request_test.dart @@ -15,6 +15,29 @@ void main() { expect(a, isNot(equals(c))); expect(a.hashCode, equals(b.hashCode)); }); + + test('toMap carries all fields', () { + const r = CdmRequest( + serviceUuid: 'uuid', + namePattern: 'pat', + singleDevice: true, + ); + expect(r.toMap(), { + 'serviceUuid': 'uuid', + 'namePattern': 'pat', + 'singleDevice': true, + }); + }); + + test('toMap defaults singleDevice to false and omitted filters to null', + () { + const r = CdmRequest(namePattern: 'pat'); + expect(r.toMap(), { + 'serviceUuid': null, + 'namePattern': 'pat', + 'singleDevice': false, + }); + }); }); group('AssociatedDevice', () { diff --git a/test/flutter_android_cdm_method_channel_test.dart b/test/flutter_android_cdm_method_channel_test.dart new file mode 100644 index 0000000..1287466 --- /dev/null +++ b/test/flutter_android_cdm_method_channel_test.dart @@ -0,0 +1,231 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_android_cdm/flutter_android_cdm.dart'; +import 'package:flutter_android_cdm/flutter_android_cdm_method_channel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('com.withintent.flutter_android_cdm'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final platform = MethodChannelFlutterAndroidCdm(); + + // Records the last call the native side would have received, and lets each + // test decide what to return or throw. + MethodCall? lastCall; + late Object? Function(MethodCall call) handler; + + setUp(() { + lastCall = null; + handler = (_) => null; + messenger.setMockMethodCallHandler(channel, (call) async { + lastCall = call; + return handler(call); + }); + }); + + tearDown(() { + messenger.setMockMethodCallHandler(channel, null); + }); + + group('associate', () { + test('serialises every CdmRequest field', () async { + handler = (_) => { + 'id': 7, + 'address': 'AA:BB:CC:DD:EE:FF', + 'name': 'Sensor', + }; + + await platform.associate(const CdmRequest( + serviceUuid: '0000180d-0000-1000-8000-00805f9b34fb', + namePattern: 'Sensor.*', + singleDevice: true, + )); + + expect(lastCall?.method, 'associate'); + expect(lastCall?.arguments, { + 'serviceUuid': '0000180d-0000-1000-8000-00805f9b34fb', + 'namePattern': 'Sensor.*', + 'singleDevice': true, + }); + }); + + test('parses the returned device', () async { + handler = (_) => { + 'id': 7, + 'address': 'AA:BB:CC:DD:EE:FF', + 'name': 'Sensor', + }; + + final device = await platform.associate( + const CdmRequest(namePattern: 'Sensor.*'), + ); + + expect(device.id, 7); + expect(device.address, 'AA:BB:CC:DD:EE:FF'); + expect(device.name, 'Sensor'); + }); + + test('defaults a missing name to empty string', () async { + handler = (_) => {'address': 'AA:BB:CC:DD:EE:FF'}; + + final device = await platform.associate( + const CdmRequest(namePattern: '.*'), + ); + + expect(device.name, ''); + expect(device.id, isNull); + }); + + test('throws noDevice when native returns null', () async { + handler = (_) => null; + + await expectLater( + platform.associate(const CdmRequest(namePattern: '.*')), + throwsA(isA() + .having((e) => e.code, 'code', CdmErrorCode.noDevice)), + ); + }); + + test('throws noDevice when the device map has no address', () async { + handler = (_) => {'id': 1, 'name': 'X'}; + + await expectLater( + platform.associate(const CdmRequest(namePattern: '.*')), + throwsA(isA() + .having((e) => e.code, 'code', CdmErrorCode.noDevice)), + ); + }); + + test('validates the UUID before touching the channel', () async { + await expectLater( + platform.associate(const CdmRequest(serviceUuid: 'not-a-uuid')), + throwsA(isA() + .having((e) => e.code, 'code', CdmErrorCode.badArgs)), + ); + expect(lastCall, isNull, reason: 'channel must not be invoked'); + }); + + test('accepts a valid UUID', () async { + handler = (_) => {'address': 'AA'}; + + await platform.associate(const CdmRequest( + serviceUuid: '0000180D-0000-1000-8000-00805F9B34FB', + )); + + expect(lastCall?.method, 'associate'); + }); + + test('maps PlatformException codes to CdmException codes', () async { + for (final code in CdmErrorCode.values) { + handler = (_) => throw PlatformException(code: code.wire, message: 'm'); + await expectLater( + platform.associate(const CdmRequest(namePattern: '.*')), + throwsA(isA() + .having((e) => e.code, 'code', code) + .having((e) => e.message, 'message', 'm')), + reason: 'wire code ${code.wire}', + ); + } + }); + + test('maps an unknown wire code to CdmErrorCode.unknown', () async { + handler = (_) => throw PlatformException(code: 'totally_made_up'); + + await expectLater( + platform.associate(const CdmRequest(namePattern: '.*')), + throwsA(isA() + .having((e) => e.code, 'code', CdmErrorCode.unknown)), + ); + }); + }); + + group('disassociate', () { + test('forwards id and address', () async { + await platform.disassociate(id: 5, address: 'AA:BB'); + + expect(lastCall?.method, 'disassociate'); + expect(lastCall?.arguments, { + 'id': 5, + 'address': 'AA:BB', + }); + }); + + test('throws badArgs when neither id nor address is given', () async { + await expectLater( + platform.disassociate(), + throwsA(isA() + .having((e) => e.code, 'code', CdmErrorCode.badArgs)), + ); + expect(lastCall, isNull, reason: 'channel must not be invoked'); + }); + + test('maps a PlatformException', () async { + handler = (_) => + throw PlatformException(code: CdmErrorCode.disassociateFailed.wire); + + await expectLater( + platform.disassociate(id: 1), + throwsA(isA() + .having((e) => e.code, 'code', CdmErrorCode.disassociateFailed)), + ); + }); + }); + + group('getAssociations', () { + test('parses a list of devices', () async { + handler = (_) => [ + {'id': 1, 'address': 'AA', 'name': 'One'}, + {'id': 2, 'address': 'BB', 'name': 'Two'}, + ]; + + final list = await platform.getAssociations(); + + expect(list, hasLength(2)); + expect(list[0].address, 'AA'); + expect(list[1].name, 'Two'); + }); + + test('returns an empty list when native returns null', () async { + handler = (_) => null; + expect(await platform.getAssociations(), isEmpty); + }); + + test('skips entries that are not maps', () async { + handler = (_) => [ + 'garbage', + {'address': 'AA', 'name': 'One'}, + ]; + + final list = await platform.getAssociations(); + + expect(list, hasLength(1)); + expect(list.single.address, 'AA'); + }); + + test('throws noDevice when an entry is missing its address', () async { + handler = (_) => [ + {'id': 1, 'name': 'no-address'}, + ]; + + await expectLater( + platform.getAssociations(), + throwsA(isA() + .having((e) => e.code, 'code', CdmErrorCode.noDevice)), + ); + }); + + test('maps a PlatformException', () async { + handler = (_) => throw PlatformException( + code: CdmErrorCode.getAssociationsFailed.wire, + ); + + await expectLater( + platform.getAssociations(), + throwsA(isA() + .having((e) => e.code, 'code', CdmErrorCode.getAssociationsFailed)), + ); + }); + }); +} diff --git a/test/flutter_android_cdm_platform_interface_test.dart b/test/flutter_android_cdm_platform_interface_test.dart new file mode 100644 index 0000000..4960912 --- /dev/null +++ b/test/flutter_android_cdm_platform_interface_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_android_cdm/flutter_android_cdm.dart'; +import 'package:flutter_android_cdm/flutter_android_cdm_method_channel.dart'; +import 'package:flutter_android_cdm/flutter_android_cdm_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +/// A platform that does not override anything — exercises the default +/// `UnimplementedError` bodies on the abstract base. +class _DefaultPlatform extends FlutterAndroidCdmPlatform + with MockPlatformInterfaceMixin {} + +/// A platform that records what the facade forwards to it. +class _FakePlatform extends FlutterAndroidCdmPlatform + with MockPlatformInterfaceMixin { + CdmRequest? associateRequest; + int? disassociateId; + String? disassociateAddress; + bool getAssociationsCalled = false; + + @override + Future associate(CdmRequest request) async { + associateRequest = request; + return const AssociatedDevice(id: 9, address: 'AA:BB', name: 'Fake'); + } + + @override + Future disassociate({int? id, String? address}) async { + disassociateId = id; + disassociateAddress = address; + } + + @override + Future> getAssociations() async { + getAssociationsCalled = true; + return const [AssociatedDevice(id: 1, address: 'CC:DD', name: 'Listed')]; + } +} + +void main() { + final initial = FlutterAndroidCdmPlatform.instance; + + test('default instance is the method-channel implementation', () { + expect(initial, isA()); + }); + + test('instance can be replaced via the mixin token bypass', () { + final fake = _FakePlatform(); + FlutterAndroidCdmPlatform.instance = fake; + expect(FlutterAndroidCdmPlatform.instance, same(fake)); + FlutterAndroidCdmPlatform.instance = initial; + }); + + group('default implementations throw UnimplementedError', () { + final platform = _DefaultPlatform(); + + test('associate', () { + expect( + () => platform.associate(const CdmRequest(namePattern: '.*')), + throwsUnimplementedError, + ); + }); + + test('disassociate', () { + expect(() => platform.disassociate(id: 1), throwsUnimplementedError); + }); + + test('getAssociations', () { + expect(() => platform.getAssociations(), throwsUnimplementedError); + }); + }); + + group('facade delegates to the platform', () { + late _FakePlatform fake; + + setUp(() { + fake = _FakePlatform(); + FlutterAndroidCdmPlatform.instance = fake; + }); + + tearDown(() { + FlutterAndroidCdmPlatform.instance = initial; + }); + + test('associate forwards the request and returns the device', () async { + const request = CdmRequest(namePattern: 'X.*'); + final device = await FlutterAndroidCdm.instance.associate(request); + + expect(fake.associateRequest, same(request)); + expect(device.address, 'AA:BB'); + }); + + test('disassociate forwards the device id and address', () async { + await FlutterAndroidCdm.instance.disassociate( + const AssociatedDevice(id: 42, address: 'EE:FF', name: 'Z'), + ); + + expect(fake.disassociateId, 42); + expect(fake.disassociateAddress, 'EE:FF'); + }); + + test('getAssociations forwards to the platform', () async { + final list = await FlutterAndroidCdm.instance.getAssociations(); + + expect(fake.getAssociationsCalled, isTrue); + expect(list.single.name, 'Listed'); + }); + }); +}