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..5a50207 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,34 +1,62 @@
# 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.
+- 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.
+- 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.
+- 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
+
+### 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..7ae3eb4 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,52 @@
-# π± 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,108 @@ 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');
+}
```
----
+`getDeviceId()` returns `Future` to preserve the original public API.
+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.
-## βοΈ Supported Platforms
+## Platform Details
-| Platform | Support |
-| -------- | ------- |
-| Android | β
Yes |
-| iOS | β
Yes |
+### Android
----
+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.
-## π§ How It Works
+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`.
-This plugin uses **different secure layers per platform** to persist a device-unique identifier:
+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.
-### Android
+### iOS
-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:
+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.
- * 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)
+The Keychain item uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, so it
+is intended to stay on the same physical device and not migrate through backups
+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.
-### iOS
+Minimum iOS version: 13.0.
+
+## Migration From 1.x
-* Uses the [`Keychain`](https://developer.apple.com/documentation/security/keychain_services) to securely store and persist a generated UUID.
+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.
-## β
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..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
@@ -1,77 +1,135 @@
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()
}
}
- private fun getDeviceId(): String {
+ private fun getDeviceId(): String? {
val drmId = getMediaDrmId()
return drmId ?: getOrCreateStoredId()
}
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)
+ Base64.encodeToString(deviceId, Base64.NO_WRAP).takeIf { it.isNotEmpty() }
+ } catch (e: Exception) {
+ null
+ } finally {
+ try {
+ mediaDrm?.release()
+ } catch (e: Exception) {
+ // Ignore release failures; fallback storage remains available.
+ }
+ }
+ }
+
+ 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()
+
+ EncryptedSharedPreferences.create(
+ context,
+ encryptedPrefsName,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ } catch (e: Exception) {
+ null
+ }
+
+ return resolveStoredId(encryptedPreferences, fallbackPreferences)
}
- private fun getOrCreateStoredId(): String {
- 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 existingId = sharedPreferences.getString(PREF_KEY, 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 uuid = UUID.randomUUID().toString()
- sharedPreferences.edit().putString(PREF_KEY, uuid).apply()
- return uuid
+ val generatedId = idGenerator()
+ if (generatedId.isEmpty()) return null
+
+ if (persistId(encryptedPreferences, generatedId)) return generatedId
+ if (persistId(fallbackPreferences, generatedId)) return generatedId
+
+ 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(@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..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,27 +1,136 @@
package persistent_device_id.maeltoukap.me
+import android.content.SharedPreferences
import io.flutter.plugin.common.MethodCall
-import io.flutter.plugin.common.MethodChannel
+import io.flutter.plugin.common.MethodChannel.Result
import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
import org.mockito.Mockito
-/*
- * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation.
- *
- * Once you have built the plugin's example app, you can run these tests from the command
- * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or
- * you can run them directly from IDEs that support JUnit such as Android Studio.
- */
-
internal class PersistentDeviceIdPluginTest {
- @Test
- fun onMethodCall_getPlatformVersion_returnsExpectedValue() {
- val plugin = PersistentDeviceIdPlugin()
+ @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"
+ }
+
+ assertNull(result)
+ }
+
+ 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)
+
+ 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("getPlatformVersion", null)
- val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java)
- plugin.onMethodCall(call, mockResult)
+ return MockPreferences(preferences, editor)
+ }
- Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE)
- }
+ private data class MockPreferences(
+ val preferences: SharedPreferences,
+ val editor: SharedPreferences.Editor
+ )
}
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..e82b9ae 100644
--- a/example/integration_test/plugin_integration_test.dart
+++ b/example/integration_test/plugin_integration_test.dart
@@ -1,25 +1,20 @@
-// // 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..0483e94 100644
--- a/example/ios/RunnerTests/RunnerTests.swift
+++ b/example/ios/RunnerTests/RunnerTests.swift
@@ -1,27 +1,107 @@
-import Flutter
-import UIKit
+import Security
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.
+final class RunnerTests: XCTestCase {
+ func testReturnsExistingScopedId() {
+ let store = MockKeychainStore(
+ scopedResult: .value("scoped-id"),
+ legacyResult: .missing
+ )
+ let plugin = PersistentDeviceIdPlugin(keychainStore: store)
+
+ XCTAssertEqual(plugin.getOrCreateDeviceId(), "scoped-id")
+ XCTAssertTrue(store.savedValues.isEmpty)
+ }
-class RunnerTests: XCTestCase {
+ func testReturnsLegacyIdWhenMigrationSucceeds() {
+ let store = MockKeychainStore(
+ scopedResult: .missing,
+ legacyResult: .value("legacy-id"),
+ saveResult: true
+ )
+ let plugin = PersistentDeviceIdPlugin(keychainStore: store)
- func testGetPlatformVersion() {
- let plugin = PersistentDeviceIdPlugin()
+ XCTAssertEqual(plugin.getOrCreateDeviceId(), "legacy-id")
+ XCTAssertEqual(store.savedValues, ["legacy-id"])
+ }
- let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: [])
+ func testPreservesLegacyIdWhenMigrationWriteFails() {
+ let store = MockKeychainStore(
+ scopedResult: .missing,
+ legacyResult: .value("legacy-id"),
+ saveResult: false
+ )
+ let plugin = PersistentDeviceIdPlugin(keychainStore: store)
- let resultExpectation = expectation(description: "result block must be called.")
- plugin.handle(call) { result in
- XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion)
- resultExpectation.fulfill()
+ XCTAssertEqual(plugin.getOrCreateDeviceId(), "legacy-id")
}
- waitForExpectations(timeout: 1)
- }
+ 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 e3652e2..23004ac 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -1,24 +1,214 @@
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..8a71e3a 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -1,84 +1,25 @@
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
-
+ # 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:
- # 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..397f209 100644
--- a/example/test/widget_test.dart
+++ b/example/test/widget_test.dart
@@ -1,27 +1,51 @@
-// 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..09b584b
--- /dev/null
+++ b/ios/persistent_device_id/Sources/persistent_device_id/PersistentDeviceIdPlugin.swift
@@ -0,0 +1,149 @@
+import Flutter
+import Security
+import UIKit
+
+enum KeychainReadResult {
+ case value(String)
+ case missing
+ case unavailable(OSStatus)
+}
+
+protocol DeviceIdKeychainStore {
+ func read(account: String, service: String?) -> KeychainReadResult
+ func save(account: String, service: String, value: String) -> Bool
+}
+
+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
+ }
+
+ var resultData: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &resultData)
+
+ 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 .value(value)
+ }
+
+ func save(account: String, service: String, value: String) -> Bool {
+ guard !value.isEmpty, 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
+ }
+ guard updateStatus == errSecItemNotFound else {
+ return false
+ }
+
+ var addQuery = query
+ 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/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..30197a2 100644
--- a/lib/persistent_device_id.dart
+++ b/lib/persistent_device_id.dart
@@ -1,9 +1,13 @@
-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. 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 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);
+ });
}