diff --git a/.github/workflows/flutter_build_example.yml b/.github/workflows/flutter_build_example.yml index 44e419e..42cb2a0 100644 --- a/.github/workflows/flutter_build_example.yml +++ b/.github/workflows/flutter_build_example.yml @@ -20,7 +20,7 @@ jobs: uses: actions/setup-java@v6 with: distribution: temurin - java-version: 17 + java-version: 21 - name: Install Flutter uses: subosito/flutter-action@v2 @@ -39,3 +39,8 @@ jobs: run: | cd example flutter build appbundle --release + + - name: Run native Android tests + run: | + cd example/android + ./gradlew :android_id:testDebugUnitTest --console=plain diff --git a/CHANGELOG.md b/CHANGELOG.md index 3299eb9..6fc3be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.6.1 + +* Clarify Android ID lifetime, platform behavior and AGP 9 migration steps. +* Handle missing plugin registration in the example. + ## 0.6.0 * **Breaking:** Require Flutter 3.47 and Android API 24 or newer. diff --git a/README.md b/README.md index 82f1c48..cce8f88 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,27 @@ A Flutter plugin for retrieving the Android ID. ## Getting started -* Add plugin to pubspec.yaml -* Use it in your code (see examples of all methods below) +Add the matching dependency to your `pubspec.yaml`: + +**Flutter 3.47+** + +```yaml +dependencies: + android_id: ^0.6.1 +``` + +**Flutter 3.10–<3.47** + +```yaml +dependencies: + android_id: ^0.5.2+1 +``` ## Usage ```dart +import 'package:android_id/android_id.dart'; + const _androidIdPlugin = AndroidId(); final String? androidId = await _androidIdPlugin.getId(); @@ -24,6 +39,9 @@ final String? androidId = await _androidIdPlugin.getId(); Treat registration/runtime failures as `null` by wrapping the call in a `try`/`catch`: ```dart +import 'package:android_id/android_id.dart'; +import 'package:flutter/services.dart'; + const _androidIdPlugin = AndroidId(); String? androidId; @@ -79,7 +97,12 @@ The 'org.jetbrains.kotlin.android' plugin is no longer required for Kotlin support since AGP 9.0. ``` -Most Flutter apps should not need app-code changes. +Use Java 17 or newer to build the Android app. Existing apps must use AGP 9 +with `android.builtInKotlin=true` (the AGP 9 default); the temporary +`android.builtInKotlin=false` opt-out is not supported by this release. Updating +Flutter alone does not update an existing app's Gradle configuration. See the +[example Android configuration](example/android) and Flutter's +[AGP 9 migration guide](https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin/for-app-developers). ## Troubleshooting @@ -92,7 +115,7 @@ If you're experiencing `MissingPluginException`, try these steps in order: flutter run ``` -2. **Check Flutter Version**: Ensure you're using Flutter 3.47.0 or higher +2. **Check Flutter Version**: Ensure your Flutter version matches the dependency version selected above ```bash flutter --version ``` diff --git a/android/build.gradle b/android/build.gradle index 2353063..210e547 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -23,14 +23,19 @@ android { minSdk = 24 } - dependencies { - testImplementation("org.jetbrains.kotlin:kotlin-test") - testImplementation("org.mockito:mockito-core:5.20.0") - } - testOptions { + unitTests.includeAndroidResources = true + unitTests.all { + jvmArgs "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-opens=java.base/java.net=ALL-UNNAMED", + "--add-opens=java.base/java.security=ALL-UNNAMED", + "--add-opens=java.base/java.text=ALL-UNNAMED", + "--add-opens=java.base/jdk.internal.access=ALL-UNNAMED" + } unitTests.all { - useJUnitPlatform() + // Robolectric uses JUnit 4. testLogging { events "passed", "skipped", "failed", "standardOut", "standardError" @@ -46,3 +51,9 @@ kotlin { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } } + +dependencies { + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.17") + testImplementation("org.mockito:mockito-core:5.20.0") +} diff --git a/android/src/test/kotlin/dev/fluttercommunity/android_id/AndroidIdPluginTest.kt b/android/src/test/kotlin/dev/fluttercommunity/android_id/AndroidIdPluginTest.kt new file mode 100644 index 0000000..0b32ab6 --- /dev/null +++ b/android/src/test/kotlin/dev/fluttercommunity/android_id/AndroidIdPluginTest.kt @@ -0,0 +1,87 @@ +package dev.fluttercommunity.android_id + +import android.content.ContentResolver +import android.provider.Settings +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [24, 26, 36]) +class AndroidIdPluginTest { + private lateinit var plugin: AndroidIdPlugin + private lateinit var binding: FlutterPlugin.FlutterPluginBinding + private lateinit var messenger: BinaryMessenger + private lateinit var result: MethodChannel.Result + + @Before + fun setUp() { + plugin = AndroidIdPlugin() + binding = mock(FlutterPlugin.FlutterPluginBinding::class.java) + messenger = mock(BinaryMessenger::class.java) + result = mock(MethodChannel.Result::class.java) + `when`(binding.applicationContext).thenReturn(RuntimeEnvironment.getApplication()) + `when`(binding.binaryMessenger).thenReturn(messenger) + plugin.onAttachedToEngine(binding) + } + + @Test + fun returnsTheSecureAndroidId() { + Settings.Secure.putString(binding.applicationContext.contentResolver, Settings.Secure.ANDROID_ID, "abc123") + plugin.onMethodCall(MethodCall("getId", null), result) + verify(result).success("abc123") + verifyNoMoreInteractions(result) + } + + @Test + fun returnsNullWhenTheSettingIsUnavailable() { + Settings.Secure.putString(binding.applicationContext.contentResolver, Settings.Secure.ANDROID_ID, null) + plugin.onMethodCall(MethodCall("getId", null), result) + verify(result).success(null) + verifyNoMoreInteractions(result) + } + + @Test + @Config(shadows = [DeniedSecureSettings::class]) + fun returnsPlatformErrorWhenTheSettingCannotBeRead() { + plugin.onMethodCall(MethodCall("getId", null), result) + verify(result).error("ERROR_GETTING_ID", "Failed to get Android ID", "Access denied") + verifyNoMoreInteractions(result) + } + + @Test + fun unknownMethodsAreNotImplemented() { + plugin.onMethodCall(MethodCall("unknown", null), result) + verify(result).notImplemented() + verifyNoMoreInteractions(result) + } + + @Test + fun detachingUnregistersTheChannel() { + clearInvocations(messenger) + plugin.onDetachedFromEngine(binding) + verify(messenger).setMessageHandler("android_id", null) + } +} + +@Implements(Settings.Secure::class) +class DeniedSecureSettings { + companion object { + @JvmStatic + @Implementation + fun getString(resolver: ContentResolver, name: String): String? { + if (name == Settings.Secure.ANDROID_ID) throw SecurityException("Access denied") + return null + } + } +} diff --git a/example/README.md b/example/README.md index 866d39e..174785f 100644 --- a/example/README.md +++ b/example/README.md @@ -1,16 +1,14 @@ # android_id_example -Demonstrates how to use the android_id plugin. +Shows the Android ID and handles missing registration or lookup failures. Other platforms display an unavailable ID. -## Getting Started +Flutter 3.47 or newer. Run on Android API 24 or newer to retrieve an ID. -This project is a starting point for a Flutter application. +For Android tooling and platform requirements, see the [package README](../README.md). -A few resources to get you started if this is your first Flutter project: +From this directory, select a compatible Flutter SDK with FVM, then run: -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +```sh +fvm flutter pub get +fvm flutter run -d +``` diff --git a/example/android/.gitignore b/example/android/.gitignore index 6f56801..824cb2c 100644 --- a/example/android/.gitignore +++ b/example/android/.gitignore @@ -11,3 +11,6 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks + +# Kotlin compiler session state +.kotlin/ diff --git a/example/integration_test/android_id_test.dart b/example/integration_test/android_id_test.dart index ca794c6..d3f2db8 100644 --- a/example/integration_test/android_id_test.dart +++ b/example/integration_test/android_id_test.dart @@ -37,14 +37,13 @@ void main() { expect(id1, equals(id2), reason: 'Android ID should be consistent across multiple calls'); }); - testWidgets('getId should work after hot restart', (WidgetTester tester) async { - // This test verifies that the plugin remains registered and functional - // after pumping frames. Note: Hot restart cannot be simulated in integration tests. + testWidgets('getId remains consistent after frames settle', (WidgetTester tester) async { + // Pumping frames verifies repeated calls; this does not simulate hot restart. final id = await androidIdPlugin.getId(); expect(id, isNotNull); - // Pump frames to simulate app lifecycle + // Allow scheduled frames to settle await tester.pumpAndSettle(); // Should still work diff --git a/example/lib/main.dart b/example/lib/main.dart index 69c4a1e..ac0ba3c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -31,6 +31,8 @@ class _MyAppState extends State { // We also handle the message potentially returning null. try { androidId = await _androidIdPlugin.getId() ?? 'Unknown ID'; + } on MissingPluginException { + androidId = 'Android ID plugin is not registered.'; } on PlatformException { androidId = 'Failed to get Android ID.'; } diff --git a/example/pubspec.lock b/example/pubspec.lock index 2ac18d7..7267265 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -7,7 +7,7 @@ packages: path: ".." relative: true source: path - version: "0.6.0" + version: "0.6.1" async: dependency: transitive description: diff --git a/example/test/android_id_example_test.dart b/example/test/android_id_example_test.dart index 5100a36..7c60923 100644 --- a/example/test/android_id_example_test.dart +++ b/example/test/android_id_example_test.dart @@ -39,4 +39,13 @@ void main() { findsOneWidget, ); }); + + testWidgets('Shows a registration error when the plugin is missing', (tester) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (_) async { + throw MissingPluginException(); + }); + await tester.pumpWidget(const MyApp()); + await tester.pumpAndSettle(); + expect(find.text('Android ID: Android ID plugin is not registered.'), findsOneWidget); + }); } diff --git a/lib/android_id.dart b/lib/android_id.dart index ae46053..e5a86a1 100644 --- a/lib/android_id.dart +++ b/lib/android_id.dart @@ -8,7 +8,14 @@ class AndroidId { /// The method channel used to interact with the native platform. static const _methodChannel = MethodChannel('android_id'); - /// Calls the native method to retrieve the Android ID. + /// Returns Android's `Settings.Secure.ANDROID_ID`, or `null` when unavailable. + /// + /// Returns `null` on non-Android platforms, including web. On Android 8.0 and + /// newer, the ID is scoped to the app-signing key, user, and device; it can + /// change after a factory reset or signing-key change. + /// + /// Throws [MissingPluginException] when the Android plugin is not registered + /// and [PlatformException] when the native lookup fails. Future getId() async { final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; if (!isAndroid) return null; diff --git a/pubspec.yaml b/pubspec.yaml index be9096f..6903a77 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,7 +13,7 @@ topics: - android - native -version: 0.6.0 +version: 0.6.1 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/test/android_id_test.dart b/test/android_id_test.dart index 4c45285..dfa0c75 100644 --- a/test/android_id_test.dart +++ b/test/android_id_test.dart @@ -6,49 +6,54 @@ import 'package:flutter_test/flutter_test.dart'; void main() { const plugin = AndroidId(); const channel = MethodChannel('android_id'); - const mockAndroidId = '42'; - - TestWidgetsFlutterBinding.ensureInitialized(); - debugDefaultTargetPlatformOverride = TargetPlatform.android; + final binding = TestWidgetsFlutterBinding.ensureInitialized(); + final calls = []; setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (methodCall) async => mockAndroidId); + debugDefaultTargetPlatformOverride = TargetPlatform.android; + calls.clear(); + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return '42'; + }); }); tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + debugDefaultTargetPlatformOverride = null; + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); }); - test('getAndroidId', () async { - expect(await plugin.getId(), mockAndroidId); + test('getId calls the native lookup', () async { + expect(await plugin.getId(), '42'); + expect(calls.single.method, 'getId'); + expect(calls.single.arguments, isNull); }); - test('returns null on non-Android platforms', () async { - addTearDown(() => debugDefaultTargetPlatformOverride = null); - - debugDefaultTargetPlatformOverride = TargetPlatform.fuchsia; - - expect(await plugin.getId(), isNull); - - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - - expect(await plugin.getId(), isNull); - - debugDefaultTargetPlatformOverride = TargetPlatform.linux; - - expect(await plugin.getId(), isNull); - - debugDefaultTargetPlatformOverride = TargetPlatform.macOS; - - expect(await plugin.getId(), isNull); - - debugDefaultTargetPlatformOverride = TargetPlatform.windows; - + test('preserves a null native result', () async { + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (_) async => null); expect(await plugin.getId(), isNull); + }); - debugDefaultTargetPlatformOverride = TargetPlatform.android; + test('preserves a native lookup failure', () async { + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (_) async { + throw PlatformException(code: 'ERROR_GETTING_ID', message: 'Failed to get Android ID'); + }); + await expectLater( + plugin.getId(), + throwsA(isA().having((error) => error.code, 'code', 'ERROR_GETTING_ID')), + ); + }); - expect(await plugin.getId(), mockAndroidId); + test('reports missing Android registration', () async { + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + await expectLater(plugin.getId(), throwsA(isA())); }); + + for (final platform in TargetPlatform.values.where((platform) => platform != TargetPlatform.android)) { + test('returns null without a native call on ${platform.name}', () async { + debugDefaultTargetPlatformOverride = platform; + expect(await plugin.getId(), isNull); + expect(calls, isEmpty); + }); + } }