Repository navigation
feat(ai): add AIClientIdentity.installId for a per-install client id - #43
VelikovPetar wants to merge 3 commits into
Conversation
`AIClientIdentity.installId()` creates `flutter-<UUID>` on first use and keeps it in shared preferences under `io.getstream.ai.client-id`, for passing as `clientId`. On Android the id is in a preferences file of its own, so an app's backup rules can exclude it; the README has the rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AIClientIdentity.installId for a per-install client id
The previous lockfile was resolved with an older Flutter SDK, which downgraded the packages it pins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xsahil03x
left a comment
There was a problem hiding this comment.
Thanks! Matching iOS and Android's installId closes a real gap for client tools on Flutter. I ran it on an Android emulator: the id lands in shared_prefs/io.getstream.ai.client-id.xml and is the same after a force-stop and relaunch. Analyze and tests pass, and the example builds for web and macOS.
Please do this before merging:
- On Android the id is backed up unless every app adds the README's rules. The default Flutter template leaves backup on; the example app reports
ALLOW_BACKUPand has no rules. A restore or device transfer then gives two phones the sameclientId, and both run a call meant for one, which is the case this API exists to prevent. Native Android keeps the id innoBackupFilesDir(AIClientToolRunner.kt:219-227), which is safe with no app config. Could we store it underno_backuptoo, e.g. as a file next togetApplicationSupportDirectory()'s parent, which isfilesDiron Android? If that's not an option, the backup warning should open the dartdoc and the CHANGELOG entry.
The rest is optional and inline.
| static Future<String> installId() { | ||
| return _pendingInstallId ??= _readOrCreateInstallId().whenComplete(() => _pendingInstallId = null); | ||
| } |
There was a problem hiding this comment.
Optional, but cheap: keep the id in memory once it's known. On iOS/macOS the id sits in UserDefaults.standard without a prefix (as it does on web and desktop), so an app calling SharedPreferencesAsync().clear() on logout (common) wipes it. The next installId() then makes a new id, while an AIClientIdentity built earlier in the session still holds the old one, so calls addressed to the old id never run. A static cache keeps a launch consistent. The tests would need a @visibleForTesting reset.
| static Future<String> installId() { | |
| return _pendingInstallId ??= _readOrCreateInstallId().whenComplete(() => _pendingInstallId = null); | |
| } | |
| static Future<String> installId() { | |
| if (_installId case final id?) return SynchronousFuture(id); | |
| return _pendingInstallId ??= _readOrCreateInstallId() | |
| .then((id) => _installId = id) | |
| .whenComplete(() => _pendingInstallId = null); | |
| } |
There was a problem hiding this comment.
I'm leaning towards not caching, but I'd like your take. What has to agree is the client_id on each message and the id the runner matches against. Nothing ties the id to a launch: the agent copies each message's client_id onto the call.
Native iOS and Android don't cache either. Their docs read installID for the runner and again on every send, and say to keep one runner per sign-in. With that lifecycle, a logout that calls clear() also drops the runner. The next sign-in then reads and stores a fresh id, which stays stable from there on.
With a cache, the old id would live on in memory but not in storage, so the next launch would silently switch ids. The cache only helps apps that wipe preferences while a runner is alive. So I'd rather document the clear() caveat (iOS, macOS, web and desktop) and the one-runner-per-sign-in rule. WDYT?
There was a problem hiding this comment.
Agreed, no cache: it matches iOS and Android. Could you add the one-runner-per-sign-in note you mentioned? It isn't in the README or the dartdoc yet.
| if (storedId != null) return storedId; | ||
|
|
||
| final newId = 'flutter-${const Uuid().v4().toUpperCase()}'; | ||
| await preferences.setString(_installIdKey, newId); |
There was a problem hiding this comment.
Optional: two engines in one process can both create an id. For example, on a cold start from a push, the background FlutterEngine and the main one both read null. The memo only covers one isolate, whereas native Android's installId is @Synchronized. Reading the value back after setString and returning what's stored narrows the window.
| /// exactly that file. Other platforms use their default store. | ||
| const SharedPreferencesOptions kAIInstallIdPreferencesOptions = SharedPreferencesAsyncAndroidOptions( | ||
| backend: SharedPreferencesAndroidBackendLibrary.SharedPreferences, | ||
| originalSharedPreferencesOptions: AndroidSharedPreferencesStoreOptions(fileName: 'io.getstream.ai.client-id'), |
There was a problem hiding this comment.
nit: the README's backup XML names this file, so a rename would silently un-exclude the id in apps that already use the rules.
| originalSharedPreferencesOptions: AndroidSharedPreferencesStoreOptions(fileName: 'io.getstream.ai.client-id'), | |
| // Named in the README's Android backup rules: renaming it un-excludes the id from existing apps' backups. | |
| originalSharedPreferencesOptions: AndroidSharedPreferencesStoreOptions(fileName: 'io.getstream.ai.client-id'), |
|
|
||
| await AIClientIdentity.installId(); | ||
|
|
||
| expect( |
There was a problem hiding this comment.
nit: everyElement passes on an empty list, so this test still passes if installId stops going through getString/setString.
| expect( | |
| expect(preferences.options, isNotEmpty); | |
| expect( |
Agreed, It adds |
- `AIClientIdentity.installId()` creates a new id when the stored one is empty or blank, instead of returning it forever. - The dartdoc and README no longer describe the id's format or storage key, and say that `SharedPreferencesAsync().clear()` removes the id on iOS, macOS, web and desktop, and that iOS backups copy it. - The README explains the `StateError` widget tests hit and the in-memory store that fixes it. - `installId()` gets its own CHANGELOG entry. - Tests cover a failed write, and check that a retried id is stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Yes to
That drops Could you keep the read/create logic behind a small internal store class rather than inside It does stop sharing the id with native iOS, which keeps it in |
Linear: FLU-
Description of the pull request
Adds
AIClientIdentity.installId(), an id for this install of the app to pass asclientId. iOS (AIClientIdentity.installID) and Android (AIClientIdentity.installId(context)) already ship this. Apps such as the Vision Agents Flutter demo can now address client tool calls to one install the same way.flutter-followed by an uppercase UUID. It's created on first use and stored withshared_preferencesunderio.getstream.ai.client-id(the key iOS uses too). Calls that race the first one get the same id. If the id can't be read or stored, the call fails and the next call tries again.shared_prefs/io.getstream.ai.client-id.xml, which an app can exclude exactly. A package can't set backup rules for the app, so the README gives thedata_extraction_rules.xml,backup_rules.xmland manifest lines. The defaultshared_preferencesstore is shared with the app's own prefs, so excluding it would exclude those too.android/orios/folder and no native code. The Android options class is only exported fromshared_preferences_android, hence the direct dependency. That package is already the endorsed Android implementation ofshared_preferences. Web builds use a conditional import, so they never import it: versions 2.5.0–2.5.2 of that package broke web builds.shared_preferences: ^2.5.3,shared_preferences_android: ^2.4.0anduuid: ^4.5.1, the same constraints the video and feeds SDKs use.shared_preferences_platform_interfaceis a dev dependency for the tests.Testing
SharedPreferencesAsyncPlatform. They cover the id's format, a later call, racing calls, an id kept from an earlier launch, the Android file options, and failure and retry. Analyze and format are clean.stream_chat_flutter10.6.0,stream_feeds0.6.2 andstream_video_flutter/stream_video_push_notification1.7.2 resolves to identical versions with and without this package.shared_prefs/io.getstream.ai.client-id.xml;clientIdset runs a device tool.clientIdset. The local router's agent never called the approval tool.main, so that's this machine's rendering, not this change.🤖 Generated with Claude Code