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 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,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); + }); }