From 8ae926002cce0b1d3bcf7aa3a4740ec45893dac7 Mon Sep 17 00:00:00 2001 From: khushal-chothani Date: Wed, 3 Jun 2026 16:28:08 +0530 Subject: [PATCH 1/2] Upgrade to version 2.0.0 with major changes including Dart SDK floor raise, improved iOS Keychain handling, and added Swift Package Manager support. Updated README and documentation for clarity, and added tests for API stability. Removed legacy Android build files and migrated to Kotlin DSL for Gradle configuration. --- .gitignore | 2 + CHANGELOG.md | 72 +++-- Configure | 0 README.md | 153 +++++----- android/build.gradle | 69 ----- android/build.gradle.kts | 78 +++++ android/settings.gradle | 1 - android/settings.gradle.kts | 1 + android/src/main/AndroidManifest.xml | 4 +- .../maeltoukap/me/PersistentDeviceIdPlugin.kt | 74 +++-- .../PersistentDeviceIdPluginTest.kt | 25 +- example/.gitignore | 3 + example/README.md | 22 +- example/android/.gitignore | 1 + example/android/app/build.gradle | 44 --- example/android/app/build.gradle.kts | 39 +++ example/android/build.gradle | 18 -- example/android/build.gradle.kts | 24 ++ example/android/gradle.properties | 7 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- example/android/settings.gradle | 25 -- example/android/settings.gradle.kts | 26 ++ .../plugin_integration_test.dart | 36 +-- example/ios/Flutter/AppFrameworkInfo.plist | 2 - example/ios/Runner.xcodeproj/project.pbxproj | 32 +- .../xcshareddata/xcschemes/Runner.xcscheme | 19 ++ example/ios/Runner/AppDelegate.swift | 7 +- example/ios/Runner/Info.plist | 29 +- example/ios/RunnerTests/RunnerTests.swift | 31 +- example/lib/main.dart | 216 ++++++++++++- example/pubspec.lock | 283 ------------------ example/pubspec.yaml | 71 +---- example/test/widget_test.dart | 64 ++-- ios/Classes/PersistentDeviceIdPlugin.swift | 60 ---- ios/persistent_device_id.podspec | 25 +- ios/persistent_device_id/Package.swift | 27 ++ .../PersistentDeviceIdPlugin.swift | 100 +++++++ .../PrivacyInfo.xcprivacy | 0 lib/persistent_device_id.dart | 11 +- lib/persistent_device_id_method_channel.dart | 5 +- ...rsistent_device_id_platform_interface.dart | 8 +- pubspec.yaml | 67 +---- ...sistent_device_id_method_channel_test.dart | 31 +- test/persistent_device_id_test.dart | 40 ++- 44 files changed, 975 insertions(+), 879 deletions(-) create mode 100644 Configure delete mode 100644 android/build.gradle create mode 100644 android/build.gradle.kts delete mode 100644 android/settings.gradle create mode 100644 android/settings.gradle.kts delete mode 100644 example/android/app/build.gradle create mode 100644 example/android/app/build.gradle.kts delete mode 100644 example/android/build.gradle create mode 100644 example/android/build.gradle.kts delete mode 100644 example/android/settings.gradle create mode 100644 example/android/settings.gradle.kts delete mode 100644 example/pubspec.lock delete mode 100644 ios/Classes/PersistentDeviceIdPlugin.swift create mode 100644 ios/persistent_device_id/Package.swift create mode 100644 ios/persistent_device_id/Sources/persistent_device_id/PersistentDeviceIdPlugin.swift rename ios/{Resources => persistent_device_id/Sources/persistent_device_id}/PrivacyInfo.xcprivacy (100%) diff --git a/.gitignore b/.gitignore index ac5aa98..41a2d6e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ migrate_working_dir/ **/doc/api/ .dart_tool/ build/ +.build/ +.swiftpm/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e78ac..22be5b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,34 +1,42 @@ # Changelog -All notable changes to this project will be documented in this file. - -## [1.1.0] - 2025-06-11 - -### ✨ Added -- βœ… iOS support using `keychain` for persistent device identification -- Automatic fallback to UUID stored in the Keychain on iOS -- Improved platform abstraction layer -- Updated README and documentation - ---- - -## [1.0.0] - 2025-06-10 - -### ✨ Added -- Initial release of `persistent_device_id` πŸŽ‰ -- Support for Android devices using: - - `MediaDrm.deviceUniqueId` for hardware-based device IDs (API 18+) - - Fallback with securely stored UUID using Android Keystore + EncryptedSharedPreferences -- Public method: `PersistentDeviceId.getDeviceId()` to retrieve the unique ID -- Example app demonstrating usage - -### βœ… Platform support -- βœ… Android (API 21+) -- 🚧 iOS: not yet implemented (planned) - -### πŸ” Security -- Encrypted storage using AndroidX Security library -- No need for runtime permissions -- Persistent across uninstalls (thanks to MediaDrm) - ---- +## 2.0.0 - 2026-06-03 + +### Changed +- Raised the package SDK floor to Dart `^3.11.0` and Flutter `>=3.41.0`. +- Kept the public API stable as `PersistentDeviceId.getDeviceId()`. +- Routed the Dart API through the platform interface and removed scaffolded + `getPlatformVersion` code. +- Updated AndroidX Security Crypto to stable `1.1.0`. +- Lowered Android minSdk to 21. +- Moved iOS source into a Swift Package Manager-friendly layout while keeping + CocoaPods support. +- Updated iOS Keychain storage to use a service-scoped item and migrate the + legacy account-only item from earlier releases. +- Rewrote README documentation with clearer guarantees, platform differences, + and persistence limitations. +- Refreshed package metadata, tests, and the example app for publish readiness. + +### Added +- Swift Package Manager manifest for iOS. +- Privacy manifest bundling through both Swift Package Manager and CocoaPods. +- Dart tests for API stability, repeated calls, method-channel forwarding, and + nullable platform responses. +- Android native unit tests for `getDeviceId` and unknown method handling. + +## 1.1.0 - 2025-06-11 + +### Added +- iOS support using Keychain for persistent device identification. +- Automatic fallback to a generated UUID stored in the Keychain on iOS. +- Improved platform abstraction layer. +- Updated README and documentation. + +## 1.0.0 - 2025-06-10 + +### Added +- Initial release of `persistent_device_id`. +- Android support using `MediaDrm.deviceUniqueId` where available. +- Fallback UUID storage using Android Keystore and encrypted shared preferences. +- Public method: `PersistentDeviceId.getDeviceId()`. +- Example app demonstrating usage. diff --git a/Configure b/Configure new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index f52d2d1..fc9e93b 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,52 @@ Persistent Device ID Logo -# πŸ“± persistent\_device\_id +# persistent_device_id -A Flutter plugin that provides a **unique, persistent, and secure** device identifierβ€”**even after reinstalling the app or resetting the device**. -Supports **Android** and **iOS** using system-level cryptography and secure storage. +A Flutter plugin that returns a persistent, app-scoped device identifier for +Android and iOS. ---- +The public API stays intentionally small: -## ✨ Features +```dart +final deviceId = await PersistentDeviceId.getDeviceId(); +``` + +## What This ID Is + +`persistent_device_id` returns an identifier that is stable across repeated app +launches on the same platform installation. It is designed for app diagnostics, +fraud signals, rate limiting, abuse prevention, and other cases where a stable +client-side installation/device signal is useful. + +## What This ID Is Not -* πŸ”’ Generates a **unique and persistent ID per device** -* ♻️ Persists across app reinstalls, cache wipes, and even factory resets (when possible) -* πŸ›‘οΈ Uses **MediaDrm**, **Android Keystore**, and **EncryptedSharedPreferences** on Android -* 🍏 Uses **Keychain** on iOS -* 🚫 Requires **no runtime permissions** -* πŸ“¦ Simple, asynchronous API +This value is not a proof of identity, authentication credential, advertising +identifier, or guaranteed hardware serial number. Do not use it as the only +security control for accounts, payments, licensing, or access decisions. ---- +The identifier can change when platform storage is cleared, a device is reset, +operating system behavior changes, or an app is restored/migrated in a way that +does not preserve the underlying storage. -## πŸ“¦ Installation +## Supported Platforms -Add this to your `pubspec.yaml`: +| Platform | Support | Storage strategy | +| -------- | ------- | ---------------- | +| Android | Yes | MediaDrm when available, otherwise generated UUID in encrypted app storage | +| iOS | Yes | Generated UUID stored in Keychain | + +macOS, Windows, Linux, and Web are intentionally not declared in this release. +Browser storage cannot provide the same security or persistence guarantees, and +desktop support should be added only with platform-specific persistence and +tests. + +## Installation + +Add the package to your `pubspec.yaml`: ```yaml dependencies: - persistent_device_id: + persistent_device_id: ^2.0.0 ``` Then run: @@ -33,87 +55,82 @@ Then run: flutter pub get ``` ---- - -## πŸ› οΈ Usage - -### Import the package +## Usage ```dart import 'package:persistent_device_id/persistent_device_id.dart'; -``` -### Get the device ID - -```dart -final deviceId = await PersistentDeviceId.getDeviceId(); -print("Device ID: $deviceId"); +Future loadDeviceId() async { + final deviceId = await PersistentDeviceId.getDeviceId(); + print('Device ID: $deviceId'); +} ``` ---- - -## βš™οΈ Supported Platforms - -| Platform | Support | -| -------- | ------- | -| Android | βœ… Yes | -| iOS | βœ… Yes | - ---- +`getDeviceId()` returns `Future` to preserve the original public API. +Android and iOS implementations are expected to return a non-null value in +normal operation. -## 🧠 How It Works - -This plugin uses **different secure layers per platform** to persist a device-unique identifier: +## Platform Details ### Android -1. Attempts to derive a hardware-based ID from [`MediaDrm`](https://developer.android.com/reference/android/media/MediaDrm) (API β‰₯ 18). -2. If `MediaDrm` is not supported or fails (e.g. on rooted/custom ROM devices), falls back to: +Android first attempts to read a Widevine `MediaDrm` device identifier. When +that is unavailable or fails, the plugin generates a UUID and stores it in +`EncryptedSharedPreferences`, protected by AndroidX Security and Android +Keystore where available. + +If encrypted storage cannot be initialized, the plugin falls back to app-private +preferences so the API can still return a stable generated value. That fallback +is less tamper-resistant than encrypted storage and is documented here so apps +can make an informed risk decision. - * A generated UUID - * Stored in [`EncryptedSharedPreferences`](https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences) - * Protected by the [`Android Keystore`](https://developer.android.com/training/articles/keystore) +Minimum Android SDK: 21. ### iOS -* Uses the [`Keychain`](https://developer.apple.com/documentation/security/keychain_services) to securely store and persist a generated UUID. +iOS generates a UUID and stores it in Keychain using a service-scoped generic +password item. Version 2.0.0 also migrates the legacy account-only Keychain item +used by earlier releases so existing apps can keep their previous identifier. + +The Keychain item uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, so it +is intended to stay on the same physical device and not migrate through backups +to another device. ---- +Minimum iOS version: 13.0. -## βœ… Android Requirements +## Persistence Limits -* **minSdkVersion**: 21 -* **compileSdkVersion**: 34 -* No special permissions needed +The ID is persistent, but not immutable: ---- +- App data clearing can reset Android fallback storage. +- iOS Keychain behavior after uninstall can vary by OS version, app group, and + installation history. +- Factory reset can reset identifiers. +- Device restore, backup migration, or OS policy changes can reset identifiers. +- Rooted, jailbroken, emulated, or heavily customized devices can behave + differently. -## 🚧 Limitations +If your app needs a durable user identity, use your own authenticated backend +identity and treat this package as an additional device/install signal. -* `MediaDrm` only available on Android **API β‰₯ 18** -* On some custom or rooted ROMs, `MediaDrm` may be unreliable -* Factory reset will remove the ID unless hardware-backed -* On iOS, Keychain-based ID may reset **if iCloud Keychain is disabled** or device is **restored without backup** +## Apple Packaging ---- +The iOS implementation supports modern Flutter Apple packaging with Swift +Package Manager-friendly source layout while keeping CocoaPods support for +projects that have not migrated yet. -## πŸ” Example +## Example -Clone the repository and run the example app: +Run the bundled example app: ```bash cd example flutter run ``` ---- - -## πŸ“„ License - -MIT License. Β© 2025 Mael Toukap. - ---- +The example shows loading, refreshing, copying, and displaying error/null states +for the device ID. -## πŸ™‹β€β™‚οΈ Contributing +## License -Contributions are welcome! Please open an issue or submit a pull request on [GitHub](https://github.com/maeltoukap/persistent_device_id) +MIT License. See [LICENSE](LICENSE). diff --git a/android/build.gradle b/android/build.gradle deleted file mode 100644 index f7c671a..0000000 --- a/android/build.gradle +++ /dev/null @@ -1,69 +0,0 @@ -group = "persistent_device_id.maeltoukap.me" -version = "1.0-SNAPSHOT" - -buildscript { - ext.kotlin_version = "1.8.22" - repositories { - google() - mavenCentral() - } - - dependencies { - classpath("com.android.tools.build:gradle:8.1.0") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - if (project.android.hasProperty("namespace")) { - namespace = "persistent_device_id.maeltoukap.me" - } - - compileSdk = 34 - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = "1.8" - } - - sourceSets { - main.java.srcDirs += "src/main/kotlin" - test.java.srcDirs += "src/test/kotlin" - } - - defaultConfig { - minSdk = 23 // ← Obligatoire pour security-crypto - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen { false } - showStandardStreams = true - } - } - } -} - -dependencies { - implementation "androidx.security:security-crypto:1.1.0-alpha06" - testImplementation "org.jetbrains.kotlin:kotlin-test" - testImplementation "org.mockito:mockito-core:5.0.0" -} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..f8790ac --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,78 @@ +group = "persistent_device_id.maeltoukap.me" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.2.20" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:9.0.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") +} + +android { + namespace = "persistent_device_id.maeltoukap.me" + + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") + } + getByName("test") { + java.srcDirs("src/test/kotlin") + } + } + + defaultConfig { + minSdk = 21 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + + it.outputs.upToDateWhen { false } + + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + implementation("androidx.security:security-crypto:1.1.0") + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") +} diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index b83990e..0000000 --- a/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'persistent_device_id' diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..d9da714 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "persistent_device_id" diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index a38259d..94cbbcf 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1 @@ - - + diff --git a/android/src/main/kotlin/persistent_device_id/maeltoukap/me/PersistentDeviceIdPlugin.kt b/android/src/main/kotlin/persistent_device_id/maeltoukap/me/PersistentDeviceIdPlugin.kt index 128bca7..2aac448 100644 --- a/android/src/main/kotlin/persistent_device_id/maeltoukap/me/PersistentDeviceIdPlugin.kt +++ b/android/src/main/kotlin/persistent_device_id/maeltoukap/me/PersistentDeviceIdPlugin.kt @@ -1,31 +1,40 @@ package persistent_device_id.maeltoukap.me import android.content.Context +import android.content.SharedPreferences import android.media.MediaDrm -import android.os.Build import android.util.Base64 -import androidx.annotation.NonNull import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result import java.util.UUID -class PersistentDeviceIdPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { +/** PersistentDeviceIdPlugin */ +class PersistentDeviceIdPlugin() : FlutterPlugin, MethodCallHandler { private lateinit var channel: MethodChannel private lateinit var context: Context - private val PREF_KEY = "device_id" + private val prefKey = "device_id" + private val encryptedPrefsName = "keystore_prefs" + private val fallbackPrefsName = "persistent_device_id_fallback_prefs" + private var deviceIdProvider: (() -> String)? = null - override fun onAttachedToEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { - context = binding.applicationContext - channel = MethodChannel(binding.binaryMessenger, "persistent_device_id") + internal constructor(deviceIdProvider: () -> String) : this() { + this.deviceIdProvider = deviceIdProvider + } + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "persistent_device_id") channel.setMethodCallHandler(this) } - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: MethodChannel.Result) { + override fun onMethodCall(call: MethodCall, result: Result) { if (call.method == "getDeviceId") { - result.success(getDeviceId()) + result.success(deviceIdProvider?.invoke() ?: getDeviceId()) } else { result.notImplemented() } @@ -37,41 +46,54 @@ class PersistentDeviceIdPlugin : FlutterPlugin, MethodChannel.MethodCallHandler } private fun getMediaDrmId(): String? { + var mediaDrm: MediaDrm? = null return try { val widevineUUID = UUID(-0x121074568629b532L, -0x5c37d8232ae2de13L) - val mediaDrm = MediaDrm(widevineUUID) + mediaDrm = MediaDrm(widevineUUID) val deviceId = mediaDrm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID) - mediaDrm.release() Base64.encodeToString(deviceId, Base64.NO_WRAP) } catch (e: Exception) { null + } finally { + try { + mediaDrm?.release() + } catch (e: Exception) { + // Ignore release failures; fallback storage remains available. + } } } private fun getOrCreateStoredId(): String { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() + return try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() - val sharedPreferences = EncryptedSharedPreferences.create( - context, - "keystore_prefs", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) + val sharedPreferences = EncryptedSharedPreferences.create( + context, + encryptedPrefsName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) - val existingId = sharedPreferences.getString(PREF_KEY, null) - if (existingId != null) { - return existingId + getOrCreateIdInPreferences(sharedPreferences) + } catch (e: Exception) { + val fallbackPreferences = context.getSharedPreferences(fallbackPrefsName, Context.MODE_PRIVATE) + getOrCreateIdInPreferences(fallbackPreferences) } + } + + private fun getOrCreateIdInPreferences(sharedPreferences: SharedPreferences): String { + val existingId = sharedPreferences.getString(prefKey, null) + if (existingId != null) return existingId val uuid = UUID.randomUUID().toString() - sharedPreferences.edit().putString(PREF_KEY, uuid).apply() + sharedPreferences.edit().putString(prefKey, uuid).apply() return uuid } - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) } } diff --git a/android/src/test/kotlin/persistent_device_id/maeltoukap/me/persistent_device_id/PersistentDeviceIdPluginTest.kt b/android/src/test/kotlin/persistent_device_id/maeltoukap/me/persistent_device_id/PersistentDeviceIdPluginTest.kt index 5cfbdae..d37b985 100644 --- a/android/src/test/kotlin/persistent_device_id/maeltoukap/me/persistent_device_id/PersistentDeviceIdPluginTest.kt +++ b/android/src/test/kotlin/persistent_device_id/maeltoukap/me/persistent_device_id/PersistentDeviceIdPluginTest.kt @@ -1,9 +1,9 @@ package persistent_device_id.maeltoukap.me import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import kotlin.test.Test +import io.flutter.plugin.common.MethodChannel.Result import org.mockito.Mockito +import kotlin.test.Test /* * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. @@ -15,13 +15,24 @@ import org.mockito.Mockito internal class PersistentDeviceIdPluginTest { @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = PersistentDeviceIdPlugin() + fun onMethodCall_getDeviceId_returnsExpectedValue() { + val plugin = PersistentDeviceIdPlugin { "test-device-id" } + + val call = MethodCall("getDeviceId", null) + val mockResult: Result = Mockito.mock(Result::class.java) + plugin.onMethodCall(call, mockResult) + + Mockito.verify(mockResult).success("test-device-id") + } + + @Test + fun onMethodCall_unknownMethod_returnsNotImplemented() { + val plugin = PersistentDeviceIdPlugin { "test-device-id" } - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + val call = MethodCall("unknownMethod", null) + val mockResult: Result = Mockito.mock(Result::class.java) plugin.onMethodCall(call, mockResult) - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + Mockito.verify(mockResult).notImplemented() } } diff --git a/example/.gitignore b/example/.gitignore index 29a3a50..291b677 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -5,9 +5,11 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ migrate_working_dir/ # IntelliJ related @@ -29,6 +31,7 @@ migrate_working_dir/ .flutter-plugins-dependencies .pub-cache/ .pub/ +/pubspec.lock /build/ # Symbolication related diff --git a/example/README.md b/example/README.md index e759e3f..7771449 100644 --- a/example/README.md +++ b/example/README.md @@ -1,16 +1,16 @@ -# persistent_device_id_example +# persistent_device_id example -Demonstrates how to use the persistent_device_id plugin. +Demonstrates `PersistentDeviceId.getDeviceId()` on Android and iOS. -## Getting Started +The example app shows: -This project is a starting point for a Flutter application. +- loading the device ID on startup +- refreshing the value +- copying the value to the clipboard +- displaying null and error states -A few resources to get you started if this is your first Flutter project: +Run it from this directory: -- [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. +```bash +flutter run +``` diff --git a/example/android/.gitignore b/example/android/.gitignore index 55afd91..8744f45 100644 --- a/example/android/.gitignore +++ b/example/android/.gitignore @@ -1,5 +1,6 @@ gradle-wrapper.jar /.gradle +/.kotlin /captures/ /gradlew /gradlew.bat diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle deleted file mode 100644 index 5fc546b..0000000 --- a/example/android/app/build.gradle +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - id "com.android.application" - id "kotlin-android" - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id "dev.flutter.flutter-gradle-plugin" -} - -android { - namespace = "persistent_device_id.maeltoukap.me_example" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "persistent_device_id.maeltoukap.me_example" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = 23 - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.debug - } - } -} - -flutter { - source = "../.." -} diff --git a/example/android/app/build.gradle.kts b/example/android/app/build.gradle.kts new file mode 100644 index 0000000..a4e8729 --- /dev/null +++ b/example/android/app/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + id("com.android.application") + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "persistent_device_id.maeltoukap.me_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "persistent_device_id.maeltoukap.me_example" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/example/android/build.gradle b/example/android/build.gradle deleted file mode 100644 index d2ffbff..0000000 --- a/example/android/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = "../build" -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean", Delete) { - delete rootProject.buildDir -} diff --git a/example/android/build.gradle.kts b/example/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 2597170..d5da727 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,3 +1,6 @@ -org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index 7bb2df6..2d428bf 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle deleted file mode 100644 index b9e43bd..0000000 --- a/example/android/settings.gradle +++ /dev/null @@ -1,25 +0,0 @@ -pluginManagement { - def flutterSdkPath = { - def properties = new Properties() - file("local.properties").withInputStream { properties.load(it) } - def flutterSdkPath = properties.getProperty("flutter.sdk") - assert flutterSdkPath != null, "flutter.sdk not set in local.properties" - return flutterSdkPath - }() - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.1.0" apply false - id "org.jetbrains.kotlin.android" version "1.8.22" apply false -} - -include ":app" diff --git a/example/android/settings.gradle.kts b/example/android/settings.gradle.kts new file mode 100644 index 0000000..950c9a5 --- /dev/null +++ b/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/example/integration_test/plugin_integration_test.dart b/example/integration_test/plugin_integration_test.dart index fb70379..96978bf 100644 --- a/example/integration_test/plugin_integration_test.dart +++ b/example/integration_test/plugin_integration_test.dart @@ -1,25 +1,19 @@ -// // This is a basic Flutter integration test. -// // -// // Since integration tests run in a full Flutter application, they can interact -// // with the host side of a plugin implementation, unlike Dart unit tests. -// // -// // For more information about Flutter integration tests, please see -// // https://flutter.dev/to/integration-testing +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:persistent_device_id/persistent_device_id.dart'; +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); -// import 'package:flutter_test/flutter_test.dart'; -// import 'package:integration_test/integration_test.dart'; + testWidgets('getDeviceId returns a stable value across repeated calls', + (_) async { + final first = await PersistentDeviceId.getDeviceId(); + final second = await PersistentDeviceId.getDeviceId(); -// import 'package:persistent_device_id/persistent_device_id.dart'; + expect(first, isNotNull); + if (first == null) return; -// void main() { -// IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - -// testWidgets('getPlatformVersion test', (WidgetTester tester) async { -// final PersistentDeviceId plugin = PersistentDeviceId(); -// final String? version = await plugin.getPlatformVersion(); -// // The version string depends on the host platform running the test, so -// // just assert that some non-empty string is returned. -// expect(version?.isNotEmpty, true); -// }); -// } + expect(first, isNotEmpty); + expect(second, first); + }); +} diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 7c56964..391a902 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 12.0 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index d143a5e..89ddb9d 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -55,6 +56,9 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 784666492D4C4C64000A1A5F /* FlutterFramework */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterFramework; path = Flutter/ephemeral/Packages/.packages/FlutterFramework; sourceTree = ""; }; + 78DABEA22ED26510000E7860 /* persistent_device_id */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = persistent_device_id; path = ../../ios/persistent_device_id; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -62,6 +66,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -79,6 +84,9 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78DABEA22ED26510000E7860 /* persistent_device_id */, + 784666492D4C4C64000A1A5F /* FlutterFramework */, + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -142,6 +150,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -165,6 +176,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -346,7 +360,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -472,7 +486,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -523,7 +537,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -611,6 +625,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 8e3ca5d..d795332 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 4e72b91..d49ee14 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift index 840b829..16afa54 100644 --- a/example/ios/RunnerTests/RunnerTests.swift +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -1,27 +1,30 @@ import Flutter -import UIKit import XCTest - @testable import persistent_device_id -// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. -// -// See https://developer.apple.com/documentation/xctest for more information about using XCTest. - class RunnerTests: XCTestCase { - - func testGetPlatformVersion() { + func testGetDeviceIdReturnsStableValue() { let plugin = PersistentDeviceIdPlugin() + let call = FlutterMethodCall(methodName: "getDeviceId", arguments: []) - let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) + var firstResult: String? + let firstExpectation = expectation(description: "first result block must be called") + plugin.handle(call) { result in + firstResult = result as? String + firstExpectation.fulfill() + } + wait(for: [firstExpectation], timeout: 1) - let resultExpectation = expectation(description: "result block must be called.") + var secondResult: String? + let secondExpectation = expectation(description: "second result block must be called") plugin.handle(call) { result in - XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion) - resultExpectation.fulfill() + secondResult = result as? String + secondExpectation.fulfill() } - waitForExpectations(timeout: 1) - } + wait(for: [secondExpectation], timeout: 1) + XCTAssertFalse(firstResult?.isEmpty ?? true) + XCTAssertEqual(firstResult, secondResult) + } } diff --git a/example/lib/main.dart b/example/lib/main.dart index e3652e2..667afa6 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,24 +1,220 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:persistent_device_id/persistent_device_id.dart'; -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - final deviceId = await PersistentDeviceId.getDeviceId(); - runApp(MyApp(deviceId)); +void main() { + runApp(const PersistentDeviceIdExampleApp()); } -class MyApp extends StatelessWidget { - final String? deviceId; - const MyApp(this.deviceId, {super.key}); +class PersistentDeviceIdExampleApp extends StatelessWidget { + const PersistentDeviceIdExampleApp({ + super.key, + this.loadDeviceId = PersistentDeviceId.getDeviceId, + }); + + final Future Function() loadDeviceId; @override Widget build(BuildContext context) { return MaterialApp( - home: Scaffold( - body: Center( - child: Text('Device ID: $deviceId'), + title: 'Persistent Device ID Example', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0F766E)), + useMaterial3: true, + ), + home: DeviceIdScreen(loadDeviceId: loadDeviceId), + ); + } +} + +class DeviceIdScreen extends StatefulWidget { + const DeviceIdScreen({ + required this.loadDeviceId, + super.key, + }); + + final Future Function() loadDeviceId; + + @override + State createState() => _DeviceIdScreenState(); +} + +class _DeviceIdScreenState extends State { + String? _deviceId; + Object? _error; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadDeviceId(); + } + + Future _loadDeviceId() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final deviceId = await widget.loadDeviceId(); + if (!mounted) return; + setState(() { + _deviceId = deviceId; + _isLoading = false; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _deviceId = null; + _error = error; + _isLoading = false; + }); + } + } + + Future _copyDeviceId() async { + final deviceId = _deviceId; + if (deviceId == null || deviceId.isEmpty) return; + + await Clipboard.setData(ClipboardData(text: deviceId)); + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Device ID copied')), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('persistent_device_id'), + backgroundColor: colorScheme.surfaceContainerHighest, + ), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Persistent Device ID', + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'This example loads the Android/iOS identifier exposed by ' + 'PersistentDeviceId.getDeviceId().', + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Card( + elevation: 0, + color: colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(20), + child: _DeviceIdValue( + deviceId: _deviceId, + error: _error, + isLoading: _isLoading, + ), + ), + ), + const SizedBox(height: 20), + Wrap( + alignment: WrapAlignment.center, + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + key: const ValueKey('refresh-device-id'), + onPressed: _isLoading ? null : _loadDeviceId, + icon: const Icon(Icons.refresh), + label: const Text('Refresh'), + ), + OutlinedButton.icon( + key: const ValueKey('copy-device-id'), + onPressed: _deviceId == null ? null : _copyDeviceId, + icon: const Icon(Icons.copy), + label: const Text('Copy'), + ), + ], + ), + ], + ), + ), ), ), ); } } + +class _DeviceIdValue extends StatelessWidget { + const _DeviceIdValue({ + required this.deviceId, + required this.error, + required this.isLoading, + }); + + final String? deviceId; + final Object? error; + final bool isLoading; + + @override + Widget build(BuildContext context) { + if (isLoading) { + return const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 12), + Text('Loading device ID...'), + ], + ); + } + + if (error != null) { + return Text( + 'Failed to load device ID: $error', + style: TextStyle(color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ); + } + + final deviceId = this.deviceId; + if (deviceId == null || deviceId.isEmpty) { + return const Text( + 'The platform returned no device ID.', + textAlign: TextAlign.center, + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Device ID', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + SelectableText( + deviceId, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock deleted file mode 100644 index 83f39ac..0000000 --- a/example/pubspec.lock +++ /dev/null @@ -1,283 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.dev" - source: hosted - version: "2.11.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - characters: - dependency: transitive - description: - name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - clock: - dependency: transitive - description: - name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf - url: "https://pub.dev" - source: hosted - version: "1.1.1" - collection: - dependency: transitive - description: - name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a - url: "https://pub.dev" - source: hosted - version: "1.18.0" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - file: - dependency: transitive - description: - name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_driver: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" - url: "https://pub.dev" - source: hosted - version: "4.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - fuchsia_remote_debug_protocol: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - integration_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" - url: "https://pub.dev" - source: hosted - version: "10.0.5" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" - url: "https://pub.dev" - source: hosted - version: "3.0.5" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - lints: - dependency: transitive - description: - name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" - url: "https://pub.dev" - source: hosted - version: "4.0.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb - url: "https://pub.dev" - source: hosted - version: "0.12.16+1" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 - url: "https://pub.dev" - source: hosted - version: "1.15.0" - path: - dependency: transitive - description: - name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" - url: "https://pub.dev" - source: hosted - version: "1.9.0" - persistent_device_id: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "1.0.0" - platform: - dependency: transitive - description: - name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" - url: "https://pub.dev" - source: hosted - version: "3.1.5" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - process: - dependency: transitive - description: - name: process - sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" - url: "https://pub.dev" - source: hosted - version: "5.0.2" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" - url: "https://pub.dev" - source: hosted - version: "1.10.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" - url: "https://pub.dev" - source: hosted - version: "1.11.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.dev" - source: hosted - version: "2.1.2" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - sync_http: - dependency: transitive - description: - name: sync_http - sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" - url: "https://pub.dev" - source: hosted - version: "0.3.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - test_api: - dependency: transitive - description: - name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" - url: "https://pub.dev" - source: hosted - version: "0.7.2" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" - url: "https://pub.dev" - source: hosted - version: "14.2.5" - webdriver: - dependency: transitive - description: - name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" - url: "https://pub.dev" - source: hosted - version: "3.0.3" -sdks: - dart: ">=3.5.3 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 6b45abc..4b502da 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,84 +1,23 @@ name: persistent_device_id_example -description: "Demonstrates how to use the persistent_device_id plugin." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. +description: Demonstrates how to use the persistent_device_id plugin. +publish_to: "none" +version: 1.0.0+1 environment: - sdk: ^3.5.3 + sdk: ^3.11.0 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - persistent_device_id: - # When depending on this package from a real application you should use: - # persistent_device_id: ^x.y.z - # See https://dart.dev/tools/pub/dependencies#version-constraints - # The example app is bundled with the plugin so we use a path dependency on - # the parent directory to use the current plugin's version. path: ../ - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 - dev_dependencies: integration_test: sdk: flutter flutter_test: sdk: flutter + flutter_lints: ^6.0.0 - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^4.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 256c5b5..dbf8634 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,27 +1,53 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -// import 'package:persistent_device_id_example/main.dart'; +import 'package:persistent_device_id_example/main.dart'; void main() { - testWidgets('Verify Platform version', (WidgetTester tester) async { - // Build our app and trigger a frame. - // await tester.pumpWidget(const MyApp()); - - // Verify that platform version is retrieved. - expect( - find.byWidgetPredicate( - (Widget widget) => widget is Text && - widget.data!.startsWith('Running on:'), + testWidgets('shows the loaded device ID', (tester) async { + await tester.pumpWidget( + PersistentDeviceIdExampleApp( + loadDeviceId: () async => 'example-device-id', + ), + ); + + expect(find.text('Loading device ID...'), findsOneWidget); + + await tester.pumpAndSettle(); + + expect(find.text('example-device-id'), findsOneWidget); + expect(find.byKey(const ValueKey('copy-device-id')), findsOneWidget); + }); + + testWidgets('refreshes the device ID', (tester) async { + var calls = 0; + + await tester.pumpWidget( + PersistentDeviceIdExampleApp( + loadDeviceId: () async { + calls += 1; + return 'example-device-id-$calls'; + }, ), - findsOneWidget, ); + + await tester.pumpAndSettle(); + expect(find.text('example-device-id-1'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('refresh-device-id'))); + await tester.pumpAndSettle(); + + expect(find.text('example-device-id-2'), findsOneWidget); + }); + + testWidgets('shows a null device ID state', (tester) async { + await tester.pumpWidget( + PersistentDeviceIdExampleApp( + loadDeviceId: () async => null, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('The platform returned no device ID.'), findsOneWidget); }); } diff --git a/ios/Classes/PersistentDeviceIdPlugin.swift b/ios/Classes/PersistentDeviceIdPlugin.swift deleted file mode 100644 index ad16d0b..0000000 --- a/ios/Classes/PersistentDeviceIdPlugin.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Flutter -import UIKit -import Security - -public class PersistentDeviceIdPlugin: NSObject, FlutterPlugin { - static let key = "persistent_device_id.maeltoukap.me" - - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "persistent_device_id", binaryMessenger: registrar.messenger()) - let instance = PersistentDeviceIdPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - if call.method == "getDeviceId" { - result(getOrCreateDeviceId()) - } else { - result(FlutterMethodNotImplemented) - } - } - - func getOrCreateDeviceId() -> String { - if let existing = loadFromKeychain(key: Self.key) { - return existing - } - - let uuid = UUID().uuidString - saveToKeychain(key: Self.key, value: uuid) - return uuid - } - - func loadFromKeychain(key: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] - - var resultData: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &resultData) - - if status == errSecSuccess, let data = resultData as? Data { - return String(data: data, encoding: .utf8) - } - - return nil - } - - func saveToKeychain(key: String, value: String) { - let data = value.data(using: .utf8)! - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key, - kSecValueData as String: data - ] - - SecItemAdd(query as CFDictionary, nil) - } -} diff --git a/ios/persistent_device_id.podspec b/ios/persistent_device_id.podspec index 6046683..2d0290b 100644 --- a/ios/persistent_device_id.podspec +++ b/ios/persistent_device_id.podspec @@ -4,26 +4,23 @@ # Pod::Spec.new do |s| s.name = 'persistent_device_id' - s.version = '0.0.1' - s.summary = 'A flutter plugin to help identify unique device' + s.version = '2.0.0' + s.summary = 'Persistent app-scoped device identifiers for Flutter.' s.description = <<-DESC -A flutter plugin to help identify unique device +Provides a persistent app-scoped device identifier for Flutter apps on iOS. DESC - s.homepage = 'http://example.com' + s.homepage = 'https://github.com/maeltoukap/persistent_device_id' s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } + s.author = { 'Mael Toukap' => 'https://github.com/maeltoukap' } s.source = { :path => '.' } - s.source_files = 'Classes/**/*' + s.source_files = 'persistent_device_id/Sources/persistent_device_id/**/*.swift' + s.resource_bundles = { + 'persistent_device_id_privacy' => ['persistent_device_id/Sources/persistent_device_id/PrivacyInfo.xcprivacy'] + } s.dependency 'Flutter' - s.platform = :ios, '12.0' + s.platform = :ios, '13.0' # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' - - # If your plugin requires a privacy manifest, for example if it uses any - # required reason APIs, update the PrivacyInfo.xcprivacy file to describe your - # plugin's privacy impact, and then uncomment this line. For more information, - # see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files - # s.resource_bundles = {'persistent_device_id_privacy' => ['Resources/PrivacyInfo.xcprivacy']} + s.swift_version = '5.9' end diff --git a/ios/persistent_device_id/Package.swift b/ios/persistent_device_id/Package.swift new file mode 100644 index 0000000..14532ae --- /dev/null +++ b/ios/persistent_device_id/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "persistent_device_id", + platforms: [ + .iOS("13.0") + ], + products: [ + .library(name: "persistent-device-id", targets: ["persistent_device_id"]) + ], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework") + ], + targets: [ + .target( + name: "persistent_device_id", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework") + ], + resources: [ + .process("PrivacyInfo.xcprivacy") + ] + ) + ] +) diff --git a/ios/persistent_device_id/Sources/persistent_device_id/PersistentDeviceIdPlugin.swift b/ios/persistent_device_id/Sources/persistent_device_id/PersistentDeviceIdPlugin.swift new file mode 100644 index 0000000..cc8e1b5 --- /dev/null +++ b/ios/persistent_device_id/Sources/persistent_device_id/PersistentDeviceIdPlugin.swift @@ -0,0 +1,100 @@ +import Flutter +import Security +import UIKit + +public class PersistentDeviceIdPlugin: NSObject, FlutterPlugin { + private static let account = "persistent_device_id.maeltoukap.me" + private static let service = "persistent_device_id" + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "persistent_device_id", binaryMessenger: registrar.messenger()) + let instance = PersistentDeviceIdPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if call.method == "getDeviceId" { + result(getOrCreateDeviceId()) + } else { + result(FlutterMethodNotImplemented) + } + } + + func getOrCreateDeviceId() -> String { + if let existing = loadFromKeychain(account: Self.account, service: Self.service) { + return existing + } + + if let legacy = loadLegacyKeychainValue(account: Self.account) { + saveToKeychain(account: Self.account, service: Self.service, value: legacy) + return legacy + } + + let uuid = UUID().uuidString + saveToKeychain(account: Self.account, service: Self.service, value: uuid) + return uuid + } + + private func loadFromKeychain(account: String, service: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecAttrService as String: service, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + return loadString(query: query) + } + + private func loadLegacyKeychainValue(account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + return loadString(query: query) + } + + private func loadString(query: [String: Any]) -> String? { + var resultData: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &resultData) + + guard status == errSecSuccess, let data = resultData as? Data else { + return nil + } + + return String(data: data, encoding: .utf8) + } + + @discardableResult + private func saveToKeychain(account: String, service: String, value: String) -> Bool { + guard let data = value.data(using: .utf8) else { + return false + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecAttrService as String: service + ] + + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { + return true + } + + var addQuery = query + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + + return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess + } +} diff --git a/ios/Resources/PrivacyInfo.xcprivacy b/ios/persistent_device_id/Sources/persistent_device_id/PrivacyInfo.xcprivacy similarity index 100% rename from ios/Resources/PrivacyInfo.xcprivacy rename to ios/persistent_device_id/Sources/persistent_device_id/PrivacyInfo.xcprivacy diff --git a/lib/persistent_device_id.dart b/lib/persistent_device_id.dart index ea82c6c..079be3b 100644 --- a/lib/persistent_device_id.dart +++ b/lib/persistent_device_id.dart @@ -1,9 +1,12 @@ -import 'package:flutter/services.dart'; +import 'persistent_device_id_platform_interface.dart'; +/// Provides access to the persistent app-scoped device identifier. class PersistentDeviceId { - static const _channel = MethodChannel('persistent_device_id'); - + /// Returns the persistent identifier reported by the current platform. + /// + /// The identifier is platform-specific and can be `null` only if the platform + /// implementation cannot produce a value. static Future getDeviceId() async { - return await _channel.invokeMethod('getDeviceId'); + return PersistentDeviceIdPlatform.instance.getDeviceId(); } } diff --git a/lib/persistent_device_id_method_channel.dart b/lib/persistent_device_id_method_channel.dart index 2f301bc..11e75b6 100644 --- a/lib/persistent_device_id_method_channel.dart +++ b/lib/persistent_device_id_method_channel.dart @@ -10,8 +10,7 @@ class MethodChannelPersistentDeviceId extends PersistentDeviceIdPlatform { final methodChannel = const MethodChannel('persistent_device_id'); @override - Future getPlatformVersion() async { - final version = await methodChannel.invokeMethod('getPlatformVersion'); - return version; + Future getDeviceId() async { + return methodChannel.invokeMethod('getDeviceId'); } } diff --git a/lib/persistent_device_id_platform_interface.dart b/lib/persistent_device_id_platform_interface.dart index 8a1c2dd..3bc8872 100644 --- a/lib/persistent_device_id_platform_interface.dart +++ b/lib/persistent_device_id_platform_interface.dart @@ -8,7 +8,8 @@ abstract class PersistentDeviceIdPlatform extends PlatformInterface { static final Object _token = Object(); - static PersistentDeviceIdPlatform _instance = MethodChannelPersistentDeviceId(); + static PersistentDeviceIdPlatform _instance = + MethodChannelPersistentDeviceId(); /// The default instance of [PersistentDeviceIdPlatform] to use. /// @@ -23,7 +24,8 @@ abstract class PersistentDeviceIdPlatform extends PlatformInterface { _instance = instance; } - Future getPlatformVersion() { - throw UnimplementedError('platformVersion() has not been implemented.'); + /// Returns a persistent app-scoped device identifier. + Future getDeviceId() { + throw UnimplementedError('getDeviceId() has not been implemented.'); } } diff --git a/pubspec.yaml b/pubspec.yaml index b595892..0b6a0fb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,39 +1,34 @@ name: persistent_device_id -description: A Flutter plugin that provides a persistent device ID using Android Keystore and iOS Keychain. -version: 1.1.0 +description: A Flutter plugin that provides a persistent app-scoped device ID for Android and iOS. +version: 2.0.0 homepage: https://github.com/maeltoukap/persistent_device_id repository: https://github.com/maeltoukap/persistent_device_id issue_tracker: https://github.com/maeltoukap/persistent_device_id/issues +documentation: https://github.com/maeltoukap/persistent_device_id/blob/main/README.md +topics: + - device-id + - persistent-storage + - android + - ios +screenshots: + - description: The persistent_device_id package logo. + path: assets/persistent_device_id_logo.png environment: - sdk: ^3.5.3 - flutter: '>=3.3.0' + sdk: ^3.11.0 + flutter: ">=3.41.0" dependencies: flutter: sdk: flutter - plugin_platform_interface: ^2.0.2 + plugin_platform_interface: ^2.1.8 dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^4.0.0 + flutter_lints: ^6.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - # This section identifies this Flutter project as a plugin project. - # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) - # which should be registered in the plugin registry. This is required for - # using method channels. - # The Android 'package' specifies package in which the registered class is. - # This is required for using method channels on Android. - # The 'ffiPlugin' specifies that native code should be built and bundled. - # This is required for using `dart:ffi`. - # All these are used by the tooling to maintain consistency when - # adding or updating assets for this project. plugin: platforms: android: @@ -41,35 +36,3 @@ flutter: pluginClass: PersistentDeviceIdPlugin ios: pluginClass: PersistentDeviceIdPlugin - - # To add assets to your plugin package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/to/asset-from-package - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # To add custom fonts to your plugin package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/to/font-from-package -documentation: https://github.com/maeltoukap/persistent_device_id/blob/main/readme diff --git a/test/persistent_device_id_method_channel_test.dart b/test/persistent_device_id_method_channel_test.dart index c5a7350..9c00197 100644 --- a/test/persistent_device_id_method_channel_test.dart +++ b/test/persistent_device_id_method_channel_test.dart @@ -9,19 +9,32 @@ void main() { const MethodChannel channel = MethodChannel('persistent_device_id'); setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - (MethodCall methodCall) async { - return '42'; - }, - ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + expect(methodCall.method, 'getDeviceId'); + return 'method-channel-device-id'; + }); }); tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); }); - test('getPlatformVersion', () async { - expect(await platform.getPlatformVersion(), '42'); + test('getDeviceId forwards to the native method channel', () async { + expect(await platform.getDeviceId(), 'method-channel-device-id'); }); + + test( + 'getDeviceId allows null when the platform cannot return an ID', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (MethodCall methodCall) async => null, + ); + + expect(await platform.getDeviceId(), isNull); + }, + ); } diff --git a/test/persistent_device_id_test.dart b/test/persistent_device_id_test.dart index ca43c62..9304ae6 100644 --- a/test/persistent_device_id_test.dart +++ b/test/persistent_device_id_test.dart @@ -1,21 +1,55 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:persistent_device_id/persistent_device_id.dart'; -import 'package:persistent_device_id/persistent_device_id_platform_interface.dart'; import 'package:persistent_device_id/persistent_device_id_method_channel.dart'; +import 'package:persistent_device_id/persistent_device_id_platform_interface.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; class MockPersistentDeviceIdPlatform with MockPlatformInterfaceMixin implements PersistentDeviceIdPlatform { + MockPersistentDeviceIdPlatform(this.deviceId); + + final String? deviceId; + int calls = 0; @override - Future getPlatformVersion() => Future.value('42'); + Future getDeviceId() async { + calls += 1; + return deviceId; + } } void main() { - final PersistentDeviceIdPlatform initialPlatform = PersistentDeviceIdPlatform.instance; + final initialPlatform = PersistentDeviceIdPlatform.instance; + + tearDown(() { + PersistentDeviceIdPlatform.instance = initialPlatform; + }); test('$MethodChannelPersistentDeviceId is the default instance', () { expect(initialPlatform, isInstanceOf()); }); + + test('getDeviceId delegates through the platform interface', () async { + final platform = MockPersistentDeviceIdPlatform('device-123'); + PersistentDeviceIdPlatform.instance = platform; + + expect(await PersistentDeviceId.getDeviceId(), 'device-123'); + expect(platform.calls, 1); + }); + + test('getDeviceId preserves nullable API contract', () async { + PersistentDeviceIdPlatform.instance = MockPersistentDeviceIdPlatform(null); + + expect(await PersistentDeviceId.getDeviceId(), isNull); + }); + + test('repeated calls can return the same persistent ID', () async { + final platform = MockPersistentDeviceIdPlatform('stable-device-id'); + PersistentDeviceIdPlatform.instance = platform; + + expect(await PersistentDeviceId.getDeviceId(), 'stable-device-id'); + expect(await PersistentDeviceId.getDeviceId(), 'stable-device-id'); + expect(platform.calls, 2); + }); } From d236df64dbae6047d62060ce2b4d997c2557b4b4 Mon Sep 17 00:00:00 2001 From: khushal-chothani Date: Tue, 9 Jun 2026 12:13:23 +0530 Subject: [PATCH 2/2] Enhance persistent device ID handling with migration support and improved error management. Updated Android and iOS implementations for better persistence, added migration from legacy storage, and refined documentation. Introduced new tests for various scenarios, ensuring stability and reliability across platforms. --- CHANGELOG.md | 20 +++ README.md | 42 ++++- .../maeltoukap/me/PersistentDeviceIdPlugin.kt | 70 ++++++-- .../PersistentDeviceIdPluginTest.kt | 148 +++++++++++++--- .../plugin_integration_test.dart | 5 +- example/ios/RunnerTests/RunnerTests.swift | 117 +++++++++--- example/lib/main.dart | 16 +- example/pubspec.yaml | 6 +- example/test/widget_test.dart | 4 +- .../PersistentDeviceIdPlugin.swift | 167 +++++++++++------- lib/persistent_device_id.dart | 5 +- 11 files changed, 451 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22be5b4..5a50207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,19 @@ `getPlatformVersion` code. - Updated AndroidX Security Crypto to stable `1.1.0`. - Lowered Android minSdk to 21. +- Changed Android fallback writes to synchronous, checked persistence and added + migration from app-private fallback storage into encrypted preferences. - Moved iOS source into a Swift Package Manager-friendly layout while keeping CocoaPods support. - Updated iOS Keychain storage to use a service-scoped item and migrate the legacy account-only item from earlier releases. +- Made iOS Keychain reads status-aware so temporary storage failures return + `null` instead of creating a second identifier. - Rewrote README documentation with clearer guarantees, platform differences, and persistence limitations. - Refreshed package metadata, tests, and the example app for publish readiness. +- Kept the example integration-test plugin available to Flutter 3.44 release + builds so the generated Android plugin registrant compiles successfully. ### Added - Swift Package Manager manifest for iOS. @@ -23,6 +29,20 @@ - Dart tests for API stability, repeated calls, method-channel forwarding, and nullable platform responses. - Android native unit tests for `getDeviceId` and unknown method handling. +- Native persistence tests for failed writes, fallback migration, corrupted + values, legacy Keychain migration, and unavailable storage. + +### Migration from 1.x +- The Dart API remains `PersistentDeviceId.getDeviceId()`. +- Dart `^3.11.0` and Flutter `>=3.41.0` are now required. +- The iOS deployment target increases from 12.0 to 13.0. +- Existing iOS account-only Keychain IDs are migrated automatically on the + first call after upgrading. A readable legacy ID remains valid even if the + migration write cannot complete. +- Android app-private fallback IDs are migrated into encrypted preferences when + encrypted storage becomes available. +- A `null` result means the native implementation could not obtain or durably + persist an ID. Method channel registration errors continue to throw. ## 1.1.0 - 2025-06-11 diff --git a/README.md b/README.md index fc9e93b..7ae3eb4 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,11 @@ Future loadDeviceId() async { ``` `getDeviceId()` returns `Future` to preserve the original public API. -Android and iOS implementations are expected to return a non-null value in -normal operation. +Android and iOS return a generated ID only after it has been durably stored. +A `null` result means the native implementation could not obtain or persist a +stable ID. Method channel errors such as `MissingPluginException` are not +converted to `null` because they indicate an app integration or registration +problem. ## Platform Details @@ -79,25 +82,48 @@ that is unavailable or fails, the plugin generates a UUID and stores it in `EncryptedSharedPreferences`, protected by AndroidX Security and Android Keystore where available. -If encrypted storage cannot be initialized, the plugin falls back to app-private -preferences so the API can still return a stable generated value. That fallback -is less tamper-resistant than encrypted storage and is documented here so apps -can make an informed risk decision. +If encrypted storage cannot be initialized or written, the plugin uses +app-private `SharedPreferences`. On a later call, an existing app-private ID is +migrated into encrypted storage when it becomes available, without changing the +returned ID. New IDs are returned only after a synchronous storage write +succeeds. If neither store can persist the ID, the plugin returns `null`. -Minimum Android SDK: 21. +Minimum Android SDK: 21. AndroidX Security Crypto `1.1.0` supports API 21 and +later. The +[AndroidX Security release notes](https://developer.android.com/jetpack/androidx/releases/security) +note that AndroidKeyStore is not used by the library on API 21 and 22. ### iOS iOS generates a UUID and stores it in Keychain using a service-scoped generic password item. Version 2.0.0 also migrates the legacy account-only Keychain item used by earlier releases so existing apps can keep their previous identifier. +This migration is attempted automatically on the first `getDeviceId()` call +after upgrading. If the migration write fails, the readable legacy ID is still +returned. The Keychain item uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, so it is intended to stay on the same physical device and not migrate through backups -to another device. +to another device. Missing or corrupted entries are regenerated, but the new ID +is returned only after the Keychain write succeeds. Temporary Keychain +unavailability returns `null` rather than creating a second identity. Minimum iOS version: 13.0. +## Migration From 1.x + +Version 2.0.0 preserves `PersistentDeviceId.getDeviceId()`, but raises the +minimum tooling and platform requirements: + +- Dart `^3.11.0` and Flutter `>=3.41.0` are required. +- The iOS deployment target increases from 12.0 to 13.0. +- Existing iOS account-only Keychain IDs are migrated to the service-scoped + entry on first access. +- Android app-private fallback IDs are migrated to encrypted preferences when + encrypted storage becomes available. +- Consumers should continue handling `null`, which now specifically means no + durable native ID could be obtained. + ## Persistence Limits The ID is persistent, but not immutable: diff --git a/android/src/main/kotlin/persistent_device_id/maeltoukap/me/PersistentDeviceIdPlugin.kt b/android/src/main/kotlin/persistent_device_id/maeltoukap/me/PersistentDeviceIdPlugin.kt index 2aac448..711a8d6 100644 --- a/android/src/main/kotlin/persistent_device_id/maeltoukap/me/PersistentDeviceIdPlugin.kt +++ b/android/src/main/kotlin/persistent_device_id/maeltoukap/me/PersistentDeviceIdPlugin.kt @@ -20,9 +20,9 @@ class PersistentDeviceIdPlugin() : FlutterPlugin, MethodCallHandler { private val prefKey = "device_id" private val encryptedPrefsName = "keystore_prefs" private val fallbackPrefsName = "persistent_device_id_fallback_prefs" - private var deviceIdProvider: (() -> String)? = null + private var deviceIdProvider: (() -> String?)? = null - internal constructor(deviceIdProvider: () -> String) : this() { + internal constructor(deviceIdProvider: () -> String?) : this() { this.deviceIdProvider = deviceIdProvider } @@ -40,7 +40,7 @@ class PersistentDeviceIdPlugin() : FlutterPlugin, MethodCallHandler { } } - private fun getDeviceId(): String { + private fun getDeviceId(): String? { val drmId = getMediaDrmId() return drmId ?: getOrCreateStoredId() } @@ -51,7 +51,7 @@ class PersistentDeviceIdPlugin() : FlutterPlugin, MethodCallHandler { val widevineUUID = UUID(-0x121074568629b532L, -0x5c37d8232ae2de13L) mediaDrm = MediaDrm(widevineUUID) val deviceId = mediaDrm.getPropertyByteArray(MediaDrm.PROPERTY_DEVICE_UNIQUE_ID) - Base64.encodeToString(deviceId, Base64.NO_WRAP) + Base64.encodeToString(deviceId, Base64.NO_WRAP).takeIf { it.isNotEmpty() } } catch (e: Exception) { null } finally { @@ -63,34 +63,70 @@ class PersistentDeviceIdPlugin() : FlutterPlugin, MethodCallHandler { } } - private fun getOrCreateStoredId(): String { - return try { + private fun getOrCreateStoredId(): String? { + val fallbackPreferences = try { + context.getSharedPreferences(fallbackPrefsName, Context.MODE_PRIVATE) + } catch (e: Exception) { + null + } + + val encryptedPreferences = try { val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() - val sharedPreferences = EncryptedSharedPreferences.create( + EncryptedSharedPreferences.create( context, encryptedPrefsName, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) - - getOrCreateIdInPreferences(sharedPreferences) } catch (e: Exception) { - val fallbackPreferences = context.getSharedPreferences(fallbackPrefsName, Context.MODE_PRIVATE) - getOrCreateIdInPreferences(fallbackPreferences) + null } + + return resolveStoredId(encryptedPreferences, fallbackPreferences) } - private fun getOrCreateIdInPreferences(sharedPreferences: SharedPreferences): String { - val existingId = sharedPreferences.getString(prefKey, null) - if (existingId != null) return existingId + internal fun resolveStoredId( + encryptedPreferences: SharedPreferences?, + fallbackPreferences: SharedPreferences?, + idGenerator: () -> String = { UUID.randomUUID().toString() } + ): String? { + readStoredId(encryptedPreferences)?.let { return it } + + readStoredId(fallbackPreferences)?.let { fallbackId -> + persistId(encryptedPreferences, fallbackId) + return fallbackId + } + + val generatedId = idGenerator() + if (generatedId.isEmpty()) return null + + if (persistId(encryptedPreferences, generatedId)) return generatedId + if (persistId(fallbackPreferences, generatedId)) return generatedId - val uuid = UUID.randomUUID().toString() - sharedPreferences.edit().putString(prefKey, uuid).apply() - return uuid + return null + } + + private fun readStoredId(sharedPreferences: SharedPreferences?): String? { + return try { + sharedPreferences?.getString(prefKey, null)?.takeIf { it.isNotEmpty() } + } catch (e: Exception) { + null + } + } + + private fun persistId(sharedPreferences: SharedPreferences?, id: String): Boolean { + return try { + sharedPreferences + ?.edit() + ?.putString(prefKey, id) + ?.commit() == true + } catch (e: Exception) { + false + } } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { diff --git a/android/src/test/kotlin/persistent_device_id/maeltoukap/me/persistent_device_id/PersistentDeviceIdPluginTest.kt b/android/src/test/kotlin/persistent_device_id/maeltoukap/me/persistent_device_id/PersistentDeviceIdPluginTest.kt index d37b985..e2de666 100644 --- a/android/src/test/kotlin/persistent_device_id/maeltoukap/me/persistent_device_id/PersistentDeviceIdPluginTest.kt +++ b/android/src/test/kotlin/persistent_device_id/maeltoukap/me/persistent_device_id/PersistentDeviceIdPluginTest.kt @@ -1,38 +1,136 @@ package persistent_device_id.maeltoukap.me +import android.content.SharedPreferences import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel.Result -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. - */ +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.mockito.Mockito internal class PersistentDeviceIdPluginTest { - @Test - fun onMethodCall_getDeviceId_returnsExpectedValue() { - val plugin = PersistentDeviceIdPlugin { "test-device-id" } + @Test + fun onMethodCall_getDeviceId_returnsExpectedValue() { + val plugin = PersistentDeviceIdPlugin { "test-device-id" } + val call = MethodCall("getDeviceId", null) + val result: Result = Mockito.mock(Result::class.java) + + plugin.onMethodCall(call, result) + + Mockito.verify(result).success("test-device-id") + } + + @Test + fun onMethodCall_unknownMethod_returnsNotImplemented() { + val plugin = PersistentDeviceIdPlugin { "test-device-id" } + val call = MethodCall("unknownMethod", null) + val result: Result = Mockito.mock(Result::class.java) + + plugin.onMethodCall(call, result) + + Mockito.verify(result).notImplemented() + } + + @Test + fun resolveStoredId_regeneratesEmptyStoredValue() { + val encrypted = mockPreferences(existingId = "", commitResult = true) + val fallback = mockPreferences(existingId = null, commitResult = true) + val plugin = PersistentDeviceIdPlugin() + + val result = plugin.resolveStoredId(encrypted.preferences, fallback.preferences) { + "generated-id" + } + + assertEquals("generated-id", result) + Mockito.verify(encrypted.editor).putString("device_id", "generated-id") + Mockito.verify(encrypted.editor).commit() + Mockito.verifyNoInteractions(fallback.editor) + } + + @Test + fun resolveStoredId_recoversFromCorruptedEncryptedValue() { + val encrypted = mockPreferences( + existingId = null, + commitResult = true, + readFailure = ClassCastException("corrupted value") + ) + val fallback = mockPreferences(existingId = "fallback-id", commitResult = true) + val plugin = PersistentDeviceIdPlugin() + + val result = plugin.resolveStoredId(encrypted.preferences, fallback.preferences) + + assertEquals("fallback-id", result) + Mockito.verify(encrypted.editor).putString("device_id", "fallback-id") + Mockito.verify(encrypted.editor).commit() + } + + @Test + fun resolveStoredId_usesPlainStorageWhenEncryptedWriteFails() { + val encrypted = mockPreferences(existingId = null, commitResult = false) + val fallback = mockPreferences(existingId = null, commitResult = true) + val plugin = PersistentDeviceIdPlugin() + + val result = plugin.resolveStoredId(encrypted.preferences, fallback.preferences) { + "generated-id" + } + + assertEquals("generated-id", result) + Mockito.verify(encrypted.editor).commit() + Mockito.verify(fallback.editor).putString("device_id", "generated-id") + Mockito.verify(fallback.editor).commit() + } + + @Test + fun resolveStoredId_migratesExistingPlainFallbackToEncryptedStorage() { + val encrypted = mockPreferences(existingId = null, commitResult = true) + val fallback = mockPreferences(existingId = "fallback-id", commitResult = true) + val plugin = PersistentDeviceIdPlugin() + + val result = plugin.resolveStoredId(encrypted.preferences, fallback.preferences) + + assertEquals("fallback-id", result) + Mockito.verify(encrypted.editor).putString("device_id", "fallback-id") + Mockito.verify(encrypted.editor).commit() + Mockito.verifyNoInteractions(fallback.editor) + } + + @Test + fun resolveStoredId_returnsNullWhenAllPersistentWritesFail() { + val encrypted = mockPreferences(existingId = null, commitResult = false) + val fallback = mockPreferences(existingId = null, commitResult = false) + val plugin = PersistentDeviceIdPlugin() + + val result = plugin.resolveStoredId(encrypted.preferences, fallback.preferences) { + "generated-id" + } - val call = MethodCall("getDeviceId", null) - val mockResult: Result = Mockito.mock(Result::class.java) - plugin.onMethodCall(call, mockResult) + assertNull(result) + } - Mockito.verify(mockResult).success("test-device-id") - } + private fun mockPreferences( + existingId: String?, + commitResult: Boolean, + readFailure: RuntimeException? = null + ): MockPreferences { + val preferences = Mockito.mock(SharedPreferences::class.java) + val editor = Mockito.mock(SharedPreferences.Editor::class.java) - @Test - fun onMethodCall_unknownMethod_returnsNotImplemented() { - val plugin = PersistentDeviceIdPlugin { "test-device-id" } + val storedValue = Mockito.`when`(preferences.getString("device_id", null)) + if (readFailure == null) { + storedValue.thenReturn(existingId) + } else { + storedValue.thenThrow(readFailure) + } + Mockito.`when`(preferences.edit()).thenReturn(editor) + Mockito.`when`(editor.putString("device_id", "generated-id")).thenReturn(editor) + Mockito.`when`(editor.putString("device_id", "fallback-id")).thenReturn(editor) + Mockito.`when`(editor.commit()).thenReturn(commitResult) - val call = MethodCall("unknownMethod", null) - val mockResult: Result = Mockito.mock(Result::class.java) - plugin.onMethodCall(call, mockResult) + return MockPreferences(preferences, editor) + } - Mockito.verify(mockResult).notImplemented() - } + private data class MockPreferences( + val preferences: SharedPreferences, + val editor: SharedPreferences.Editor + ) } diff --git a/example/integration_test/plugin_integration_test.dart b/example/integration_test/plugin_integration_test.dart index 96978bf..e82b9ae 100644 --- a/example/integration_test/plugin_integration_test.dart +++ b/example/integration_test/plugin_integration_test.dart @@ -5,8 +5,9 @@ import 'package:persistent_device_id/persistent_device_id.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('getDeviceId returns a stable value across repeated calls', - (_) async { + testWidgets('getDeviceId returns a stable value across repeated calls', ( + _, + ) async { final first = await PersistentDeviceId.getDeviceId(); final second = await PersistentDeviceId.getDeviceId(); diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift index 16afa54..0483e94 100644 --- a/example/ios/RunnerTests/RunnerTests.swift +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -1,30 +1,107 @@ -import Flutter +import Security import XCTest @testable import persistent_device_id -class RunnerTests: XCTestCase { - func testGetDeviceIdReturnsStableValue() { - let plugin = PersistentDeviceIdPlugin() - let call = FlutterMethodCall(methodName: "getDeviceId", arguments: []) +final class RunnerTests: XCTestCase { + func testReturnsExistingScopedId() { + let store = MockKeychainStore( + scopedResult: .value("scoped-id"), + legacyResult: .missing + ) + let plugin = PersistentDeviceIdPlugin(keychainStore: store) - var firstResult: String? - let firstExpectation = expectation(description: "first result block must be called") - plugin.handle(call) { result in - firstResult = result as? String - firstExpectation.fulfill() + XCTAssertEqual(plugin.getOrCreateDeviceId(), "scoped-id") + XCTAssertTrue(store.savedValues.isEmpty) } - wait(for: [firstExpectation], timeout: 1) - var secondResult: String? - let secondExpectation = expectation(description: "second result block must be called") - plugin.handle(call) { result in - secondResult = result as? String - secondExpectation.fulfill() + func testReturnsLegacyIdWhenMigrationSucceeds() { + let store = MockKeychainStore( + scopedResult: .missing, + legacyResult: .value("legacy-id"), + saveResult: true + ) + let plugin = PersistentDeviceIdPlugin(keychainStore: store) + + XCTAssertEqual(plugin.getOrCreateDeviceId(), "legacy-id") + XCTAssertEqual(store.savedValues, ["legacy-id"]) + } + + func testPreservesLegacyIdWhenMigrationWriteFails() { + let store = MockKeychainStore( + scopedResult: .missing, + legacyResult: .value("legacy-id"), + saveResult: false + ) + let plugin = PersistentDeviceIdPlugin(keychainStore: store) + + XCTAssertEqual(plugin.getOrCreateDeviceId(), "legacy-id") } - wait(for: [secondExpectation], timeout: 1) - XCTAssertFalse(firstResult?.isEmpty ?? true) - XCTAssertEqual(firstResult, secondResult) - } + func testGeneratesIdAfterMissingOrCorruptedValues() { + let store = MockKeychainStore( + scopedResult: .missing, + legacyResult: .missing, + saveResult: true + ) + let plugin = PersistentDeviceIdPlugin( + keychainStore: store, + idGenerator: { "generated-id" } + ) + + XCTAssertEqual(plugin.getOrCreateDeviceId(), "generated-id") + XCTAssertEqual(store.savedValues, ["generated-id"]) + } + + func testReturnsNilWhenScopedKeychainIsUnavailable() { + let store = MockKeychainStore( + scopedResult: .unavailable(errSecNotAvailable), + legacyResult: .missing + ) + let plugin = PersistentDeviceIdPlugin(keychainStore: store) + + XCTAssertNil(plugin.getOrCreateDeviceId()) + XCTAssertTrue(store.savedValues.isEmpty) + } + + func testReturnsNilWhenGeneratedIdCannotBePersisted() { + let store = MockKeychainStore( + scopedResult: .missing, + legacyResult: .missing, + saveResult: false + ) + let plugin = PersistentDeviceIdPlugin( + keychainStore: store, + idGenerator: { "generated-id" } + ) + + XCTAssertNil(plugin.getOrCreateDeviceId()) + } +} + +private final class MockKeychainStore: DeviceIdKeychainStore { + private let scopedResult: KeychainReadResult + private let legacyResult: KeychainReadResult + private let saveResult: Bool + + private(set) var savedValues: [String] = [] + + init( + scopedResult: KeychainReadResult, + legacyResult: KeychainReadResult, + saveResult: Bool = true + ) { + self.scopedResult = scopedResult + self.legacyResult = legacyResult + self.saveResult = saveResult + } + + func read(account: String, service: String?) -> KeychainReadResult { + service == nil ? legacyResult : scopedResult + } + + func save(account: String, service: String, value: String) -> Bool { + savedValues.append(value) + return saveResult + } } diff --git a/example/lib/main.dart b/example/lib/main.dart index 667afa6..23004ac 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -28,10 +28,7 @@ class PersistentDeviceIdExampleApp extends StatelessWidget { } class DeviceIdScreen extends StatefulWidget { - const DeviceIdScreen({ - required this.loadDeviceId, - super.key, - }); + const DeviceIdScreen({required this.loadDeviceId, super.key}); final Future Function() loadDeviceId; @@ -80,9 +77,9 @@ class _DeviceIdScreenState extends State { await Clipboard.setData(ClipboardData(text: deviceId)); if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Device ID copied')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Device ID copied'))); } @override @@ -203,10 +200,7 @@ class _DeviceIdValue extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Device ID', - style: Theme.of(context).textTheme.labelLarge, - ), + Text('Device ID', style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 8), SelectableText( deviceId, diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 4b502da..8a71e3a 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -9,12 +9,14 @@ environment: dependencies: flutter: sdk: flutter + # Keep the native test plugin on the release classpath. Flutter 3.44's + # generated registrant references it in release builds. + integration_test: + sdk: flutter persistent_device_id: path: ../ dev_dependencies: - integration_test: - sdk: flutter flutter_test: sdk: flutter flutter_lints: ^6.0.0 diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index dbf8634..397f209 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -41,9 +41,7 @@ void main() { testWidgets('shows a null device ID state', (tester) async { await tester.pumpWidget( - PersistentDeviceIdExampleApp( - loadDeviceId: () async => null, - ), + PersistentDeviceIdExampleApp(loadDeviceId: () async => null), ); await tester.pumpAndSettle(); diff --git a/ios/persistent_device_id/Sources/persistent_device_id/PersistentDeviceIdPlugin.swift b/ios/persistent_device_id/Sources/persistent_device_id/PersistentDeviceIdPlugin.swift index cc8e1b5..09b584b 100644 --- a/ios/persistent_device_id/Sources/persistent_device_id/PersistentDeviceIdPlugin.swift +++ b/ios/persistent_device_id/Sources/persistent_device_id/PersistentDeviceIdPlugin.swift @@ -2,76 +2,49 @@ import Flutter import Security import UIKit -public class PersistentDeviceIdPlugin: NSObject, FlutterPlugin { - private static let account = "persistent_device_id.maeltoukap.me" - private static let service = "persistent_device_id" - - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "persistent_device_id", binaryMessenger: registrar.messenger()) - let instance = PersistentDeviceIdPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - if call.method == "getDeviceId" { - result(getOrCreateDeviceId()) - } else { - result(FlutterMethodNotImplemented) - } - } - - func getOrCreateDeviceId() -> String { - if let existing = loadFromKeychain(account: Self.account, service: Self.service) { - return existing - } - - if let legacy = loadLegacyKeychainValue(account: Self.account) { - saveToKeychain(account: Self.account, service: Self.service, value: legacy) - return legacy - } - - let uuid = UUID().uuidString - saveToKeychain(account: Self.account, service: Self.service, value: uuid) - return uuid - } - - private func loadFromKeychain(account: String, service: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: account, - kSecAttrService as String: service, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne - ] +enum KeychainReadResult { + case value(String) + case missing + case unavailable(OSStatus) +} - return loadString(query: query) - } +protocol DeviceIdKeychainStore { + func read(account: String, service: String?) -> KeychainReadResult + func save(account: String, service: String, value: String) -> Bool +} - private func loadLegacyKeychainValue(account: String) -> String? { - let query: [String: Any] = [ +final class SystemDeviceIdKeychainStore: DeviceIdKeychainStore { + func read(account: String, service: String?) -> KeychainReadResult { + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] + if let service { + query[kSecAttrService as String] = service + } - return loadString(query: query) - } - - private func loadString(query: [String: Any]) -> String? { var resultData: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &resultData) - guard status == errSecSuccess, let data = resultData as? Data else { - return nil + if status == errSecItemNotFound { + return .missing + } + guard status == errSecSuccess else { + return .unavailable(status) + } + guard let data = resultData as? Data, + let value = String(data: data, encoding: .utf8), + !value.isEmpty else { + return .missing } - return String(data: data, encoding: .utf8) + return .value(value) } - @discardableResult - private func saveToKeychain(account: String, service: String, value: String) -> Bool { - guard let data = value.data(using: .utf8) else { + func save(account: String, service: String, value: String) -> Bool { + guard !value.isEmpty, let data = value.data(using: .utf8) else { return false } @@ -80,7 +53,6 @@ public class PersistentDeviceIdPlugin: NSObject, FlutterPlugin { kSecAttrAccount as String: account, kSecAttrService as String: service ] - let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly @@ -90,11 +62,88 @@ public class PersistentDeviceIdPlugin: NSObject, FlutterPlugin { if updateStatus == errSecSuccess { return true } + guard updateStatus == errSecItemNotFound else { + return false + } var addQuery = query - addQuery[kSecValueData as String] = data - addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - + addQuery.merge(attributes) { _, new in new } return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess } } + +public class PersistentDeviceIdPlugin: NSObject, FlutterPlugin { + private static let account = "persistent_device_id.maeltoukap.me" + private static let service = "persistent_device_id" + + private let keychainStore: DeviceIdKeychainStore + private let idGenerator: () -> String + + public override init() { + keychainStore = SystemDeviceIdKeychainStore() + idGenerator = { UUID().uuidString } + super.init() + } + + init( + keychainStore: DeviceIdKeychainStore, + idGenerator: @escaping () -> String = { UUID().uuidString } + ) { + self.keychainStore = keychainStore + self.idGenerator = idGenerator + super.init() + } + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "persistent_device_id", + binaryMessenger: registrar.messenger() + ) + let instance = PersistentDeviceIdPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + if call.method == "getDeviceId" { + result(getOrCreateDeviceId()) + } else { + result(FlutterMethodNotImplemented) + } + } + + func getOrCreateDeviceId() -> String? { + switch keychainStore.read(account: Self.account, service: Self.service) { + case let .value(existing): + return existing + case .unavailable: + return nil + case .missing: + break + } + + switch keychainStore.read(account: Self.account, service: nil) { + case let .value(legacy): + _ = keychainStore.save( + account: Self.account, + service: Self.service, + value: legacy + ) + return legacy + case .unavailable: + return nil + case .missing: + break + } + + let generatedId = idGenerator() + guard !generatedId.isEmpty else { + return nil + } + + return keychainStore.save( + account: Self.account, + service: Self.service, + value: generatedId + ) ? generatedId : nil + } +} diff --git a/lib/persistent_device_id.dart b/lib/persistent_device_id.dart index 079be3b..30197a2 100644 --- a/lib/persistent_device_id.dart +++ b/lib/persistent_device_id.dart @@ -4,8 +4,9 @@ import 'persistent_device_id_platform_interface.dart'; class PersistentDeviceId { /// Returns the persistent identifier reported by the current platform. /// - /// The identifier is platform-specific and can be `null` only if the platform - /// implementation cannot produce a value. + /// The identifier is platform-specific. A `null` result means the native + /// implementation could not obtain or durably persist an identifier. + /// Method channel errors are allowed to surface as integration failures. static Future getDeviceId() async { return PersistentDeviceIdPlatform.instance.getDeviceId(); }