Skip to content

feat(ai): add AIClientIdentity.installId for a per-install client id - #43

Open
VelikovPetar wants to merge 3 commits into
mainfrom
feat/ai-client-install-id
Open

VelikovPetar wants to merge 3 commits into
mainfrom
feat/ai-client-install-id

Conversation

@VelikovPetar

Copy link
Copy Markdown
Contributor

Linear: FLU-

Description of the pull request

Adds AIClientIdentity.installId(), an id for this install of the app to pass as clientId. 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.

final identity = AIClientIdentity(
  userId: currentUser.id,
  clientId: await AIClientIdentity.installId(),
);
  • Id: flutter- followed by an uppercase UUID. It's created on first use and stored with shared_preferences under io.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.
  • Android backups: an id restored from a backup would make two devices the same install, and both would run a call meant for one. The id is therefore kept in a preferences file of its own, 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 the data_extraction_rules.xml, backup_rules.xml and manifest lines. The default shared_preferences store is shared with the app's own prefs, so excluding it would exclude those too.
  • Still pure Dart: no android/ or ios/ folder and no native code. The Android options class is only exported from shared_preferences_android, hence the direct dependency. That package is already the endorsed Android implementation of shared_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.
  • Dependencies: shared_preferences: ^2.5.3, shared_preferences_android: ^2.4.0 and uuid: ^4.5.1, the same constraints the video and feeds SDKs use. shared_preferences_platform_interface is a dev dependency for the tests.

Testing

  • Unit tests: 7 new tests against an in-memory 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.
  • Cross-SDK resolution: an app with the latest stream_chat_flutter 10.6.0, stream_feeds 0.6.2 and stream_video_flutter / stream_video_push_notification 1.7.2 resolves to identical versions with and without this package.
  • Android 15 device, Vision Agents Flutter demo pointed at this branch:
    • the id is written to shared_prefs/io.getstream.ai.client-id.xml;
    • the same id comes back after a force-stop and relaunch;
    • chat with clientId set runs a device tool.
  • Backup and restore with the README rules (local transport, uninstall, then reinstall):
    • the backup leaves the id file out;
    • a control prefs file is restored and the id file isn't;
    • the app creates a new id.
  • Not covered: the tool approval card with clientId set. The local router's agent never called the approval tool.
  • Goldens: 6 CI-variant goldens fail locally. They fail the same way on main, so that's this machine's rendering, not this change.

🤖 Generated with Claude Code

`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>
@VelikovPetar
VelikovPetar marked this pull request as ready for review October 27, 2026 13:11
@VelikovPetar
VelikovPetar requested a review from a team October 27, 2026 13:11
@VelikovPetar VelikovPetar changed the title feat(ai): add AIClientIdentity.installId for a per-install client id feat(ai): add AIClientIdentity.installId for a per-install client id Oct 27, 2026
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 xsahil03x left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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_BACKUP and has no rules. A restore or device transfer then gives two phones the same clientId, and both run a call meant for one, which is the case this API exists to prevent. Native Android keeps the id in noBackupFilesDir (AIClientToolRunner.kt:219-227), which is safe with no app config. Could we store it under no_backup too, e.g. as a file next to getApplicationSupportDirectory()'s parent, which is filesDir on Android? If that's not an option, the backup warning should open the dartdoc and the CHANGELOG entry.

The rest is optional and inline.

Comment on lines +66 to +68
static Future<String> installId() {
return _pendingInstallId ??= _readOrCreateInstallId().whenComplete(() => _pendingInstallId = null);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/stream_chat_flutter_ai/lib/src/tools/ai_client_identity.dart Outdated
if (storedId != null) return storedId;

final newId = 'flutter-${const Uuid().v4().toUpperCase()}';
await preferences.setString(_installIdKey, newId);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/stream_chat_flutter_ai/lib/src/tools/ai_client_identity.dart Outdated
/// exactly that file. Other platforms use their default store.
const SharedPreferencesOptions kAIInstallIdPreferencesOptions = SharedPreferencesAsyncAndroidOptions(
backend: SharedPreferencesAndroidBackendLibrary.SharedPreferences,
originalSharedPreferencesOptions: AndroidSharedPreferencesStoreOptions(fileName: 'io.getstream.ai.client-id'),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: everyElement passes on an empty list, so this test still passes if installId stops going through getString/setString.

Suggested change
expect(
expect(preferences.options, isNotEmpty);
expect(

Comment thread packages/stream_chat_flutter_ai/CHANGELOG.md Outdated
Comment thread packages/stream_chat_flutter_ai/README.md Outdated
@VelikovPetar

Copy link
Copy Markdown
Contributor Author

Could we store it under no_backup too, e.g. as a file next to getApplicationSupportDirectory()'s parent…

Agreed, no_backup is the right home. Before I switch, one dependency question. Dart has no way to find the app's data directory on Android, so this needs path_provider: take getApplicationSupportDirectory() (which is filesDir) and use its sibling no_backup/io.getstream.ai.client-id. That's the same file native Android uses, so an app that also embeds the native SDK shares one id.

It adds path_provider, plus path_provider_android 2.3, which brings in jni/jni_flutter, as direct dependencies. In return we can drop shared_preferences_android and the README backup rules. Most apps already depend on path_provider. Are you OK with adding it?

- `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>
@xsahil03x

xsahil03x commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Are you OK with adding it?

Yes to path_provider (^2.1.5, which stream_chat_flutter and stream_chat_persistence already depend on), but I'd go one step further and use one file-based store everywhere instead of no_backup on Android plus shared_preferences elsewhere:

  • Android: a file in no_backup (getApplicationSupportDirectory()'s parent + no_backup). Create the directory yourself; path_provider doesn't, and native only creates it through getNoBackupFilesDir().
  • iOS / macOS: a file in Application Support marked excluded from backup. UserDefaults is restored by iCloud backup and Quick Start, so it has the same two-devices problem as Android. package:objective_c (already pulled in by path_provider) exports NSURL and NSNumber, so the flag can be set from Dart with setResourceValue and NSURLIsExcludedFromBackupKey, no native code.
  • Windows / Linux: a file in Application Support.
  • Web: localStorage, behind the same store via the conditional import you already have.

That drops shared_preferences from the feature and the README backup rules (the README's widget-test section would then need a path_provider fake instead). The read/create path is one piece of code; only the directory differs per platform. It's the shape Segment's Flutter SDK uses, with the backup guarantees of Firebase Installations. I didn't find a package that does this without native code or device-level ids that survive reinstall.

Could you keep the read/create logic behind a small internal store class rather than inside AIClientIdentity? If another Stream SDK needs an install id later, it can then move to stream_core_flutter (which already depends on path_provider) as is.

It does stop sharing the id with native iOS, which keeps it in UserDefaults.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants