Skip to content

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

Merged
VelikovPetar merged 6 commits into
mainfrom
feat/ai-client-install-id
Oct 30, 2026
Merged

VelikovPetar merged 6 commits into
mainfrom
feat/ai-client-install-id

Conversation

@VelikovPetar

@VelikovPetar VelikovPetar commented Oct 27, 2026 •

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: an opaque id (today flutter- followed by an uppercase UUID), created on first use and kept across launches. Calls that race the first one get the same id. A blank stored value counts as missing. If the id can't be read or stored, the call fails and the next call tries again.
  • Storage: a file named io.getstream.ai.client-id:
    • Android: in no_backup (the parent of getApplicationSupportDirectory(), which is filesDir, plus no_backup). This is the same file the native Android SDK uses, so an app that embeds both gets one id.
    • iOS and macOS: in Application Support, marked excluded from backup with NSURL.setResourceValue through package:objective_c.
    • Windows and Linux: in Application Support.
    • Web: in localStorage, through a conditional import.
  • Backups: a restored id would make two devices the same install, and both would run a call meant for one. The id is kept out of backups on Android, iOS and macOS with no setup in the app. Native iOS keeps its id in UserDefaults, which backups restore, so the Flutter id is no longer shared with native iOS. That's a deliberate tradeoff, since an app embedding both SDKs is very unlikely.
  • Two engines: two Flutter engines in one process (e.g. a push-started background engine and the main one) create the file with File.create(exclusive: true). Only one writes the id, and the other reads it back.
  • Internal store: the read/create logic lives in an internal AIInstallIdStore, not in AIClientIdentity, so it can move to stream_core_flutter later as is.
  • Still pure Dart: no android/ or ios/ folder and no native code.
  • Dependencies: path_provider: ^2.1.5 (which stream_chat_flutter and stream_chat_persistence already use), objective_c: ^9.3.0, web: ^1.1.0 and uuid: ^4.5.1. objective_c and web were already in the lock through other dependencies. path_provider_platform_interface and plugin_platform_interface are dev dependencies for the tests.
  • Usage guidance: the dartdoc and README recommend reading the id once per sign-in, and using that same value for the runner's and approver's identity and as client_id on every message. For widget tests, they recommend a fixed clientId, or tester.runAsync with a path_provider fake.

Testing

  • Unit tests:
    • The store, against a fake storage: creating the id, later and racing calls, a kept id, a blank id being replaced, and read/write failure followed by a retry.
    • The file storage, against real files: file name and contents, backup exclusion after the write and again on read, two engines racing, waiting for another engine's write, replacing an empty file, and surfacing the real error when the file can't be created.
    • The Android directory, and the backup flag on macOS (checked with xattr).
    • Analyze and format are clean, and the publish dry-run has 0 warnings.
  • Live runs:
    • Android 15 emulator: the id is written to /data/user/0/<pkg>/no_backup/io.getstream.ai.client-id, readable only by the app, and the same id comes back after a force-stop and relaunch. A full adb backup includes a control file from files/ but nothing from no_backup.
    • iOS 26.2 simulator: the id is written to Library/Application Support, carries the backup-exclusion attribute, and comes back unchanged after two relaunches.
    • macOS: the same id across launches, and tmutil isexcluded reports the file as excluded.
    • Chrome: the id is kept in localStorage. An existing id is reused, a blank one is replaced, and an id another tab stored first wins.
  • Not covered: Windows and Linux beyond the unit tests, physical devices, and an id shared with the native Android SDK in one app.

🤖 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.

@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.

VelikovPetar and others added 2 commits October 29, 2026 20:24
…-7464aa

# Conflicts:
#	packages/stream_chat_flutter_ai/pubspec.yaml
#	pubspec.lock
#	pubspec.yaml
`AIClientIdentity.installId()` no longer uses shared preferences. The id
now lives in a file named `io.getstream.ai.client-id`:

- Android: in `no_backup`, the same file the native Android SDK uses, so
  backups and device transfers skip it with no app setup.
- iOS and macOS: in Application Support, marked excluded from backup.
- Windows and Linux: in Application Support.
- Web: in `localStorage`.

The read/create logic lives in an internal `AIInstallIdStore`. Two
engines creating the id at once go through an exclusive file create, so
they end up with one id. The README's Android backup rules are gone, and
its widget-test section now recommends a fixed `clientId`, or
`tester.runAsync` with a `path_provider` fake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@VelikovPetar

Copy link
Copy Markdown
Contributor Author

I'd go one step further and use one file-based store everywhere

Done in 30564db, following your outline:

  • Android: the id is in no_backup, and we create the directory ourselves.
  • iOS and macOS: a file in Application Support, excluded from backup via objective_c.
  • Windows and Linux: a file in Application Support.
  • Web: localStorage.

shared_preferences and the README backup rules are gone. The README's widget-test section now recommends a fixed clientId, or tester.runAsync with a PathProviderPlatform fake: real file I/O never completes inside testWidgets' fake clock. The read/create logic lives in an internal AIInstallIdStore, outside AIClientIdentity, so it can move to stream_core_flutter as is.

As you noted, iOS no longer shares the id with the native SDK. That's fine, since an app embedding both is very unlikely.

@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! One file-based store everywhere is a clean result. I tried it on an Android emulator (the id lands in no_backup, survives a force-stop, picks up an id written by the native SDK, recovers from an empty file, and pm clear resets it) and on macOS (in the sandbox container, with tmutil isexcluded reporting the file excluded). Android, iOS, macOS, web and web --wasm all build, the web store passes in Chrome under both compilers, and analyze and all tests pass.

Please do these before merging:

  1. Don't let a failed backup exclusion fail installId(): today one failure makes every later call fail although the id is on disk (inline on ai_install_id_storage_io.dart:80 and :96).
  2. Uninstalling only removes the id on Android and iOS; the dartdoc and README say it does everywhere (inline).
  3. The storage interface promises the first engine's id wins, which isn't true once the checks give up (inline).

The rest is optional and inline.

- A failed backup exclusion no longer fails `read` or `create`: the id is
  stored either way, and the next read tries the exclusion again.
- The dartdoc and README say uninstalling removes the id only on Android
  and iOS, and the storage interface says when a racing id can be replaced.
- Name the wait between checks for another engine's write, give every
  skipped test a reason, mark the install id types `@internal`, and make
  the README's widget-test snippet restore what it changes.

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 for the quick turnaround! A failed backup exclusion no longer fails read or create, and the two new tests fail against the previous commit and pass now. The uninstall wording and the create contract match what the code does, and the @internal markers stay off everything exported. Analyze, format and all tests pass, and the example still builds for web (JS and Wasm), Android and macOS.

@VelikovPetar
VelikovPetar merged commit bf4b801 into main Oct 30, 2026
8 of 10 checks passed
@VelikovPetar
VelikovPetar deleted the feat/ai-client-install-id branch October 30, 2026 17:59
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