@unisim/sdk 0.123.4 → 0.124.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +103 -6
  2. package/android/build.gradle +59 -0
  3. package/android/src/main/AndroidManifest.xml +88 -0
  4. package/android/src/main/java/uk/co/unisim/sdk/SuiteAuthProvider.java +107 -0
  5. package/android/src/main/java/uk/co/unisim/sdk/SuiteAuthStore.java +179 -0
  6. package/android/src/main/java/uk/co/unisim/sdk/UnisimSuiteAuthPlugin.java +214 -0
  7. package/dist/ChangelogMenu.js +7 -0
  8. package/dist/ChangelogMenu.js.map +1 -1
  9. package/dist/SuiteSwitcher.d.ts.map +1 -1
  10. package/dist/SuiteSwitcher.js +70 -4
  11. package/dist/SuiteSwitcher.js.map +1 -1
  12. package/dist/UniversalAppsNavBar.d.ts.map +1 -1
  13. package/dist/UniversalAppsNavBar.js +92 -6
  14. package/dist/UniversalAppsNavBar.js.map +1 -1
  15. package/dist/UniversalNavBar.d.ts +25 -3
  16. package/dist/UniversalNavBar.d.ts.map +1 -1
  17. package/dist/UniversalNavBar.js +144 -61
  18. package/dist/UniversalNavBar.js.map +1 -1
  19. package/dist/UserProfile.d.ts +108 -1
  20. package/dist/UserProfile.d.ts.map +1 -1
  21. package/dist/UserProfile.js +147 -78
  22. package/dist/UserProfile.js.map +1 -1
  23. package/dist/i18n.d.ts.map +1 -1
  24. package/dist/i18n.js +7 -0
  25. package/dist/i18n.js.map +1 -1
  26. package/dist/index.d.ts +5 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +6 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/provider.d.ts.map +1 -1
  31. package/dist/provider.js +94 -1
  32. package/dist/provider.js.map +1 -1
  33. package/dist/sessionStorage.d.ts +25 -7
  34. package/dist/sessionStorage.d.ts.map +1 -1
  35. package/dist/sessionStorage.js +49 -15
  36. package/dist/sessionStorage.js.map +1 -1
  37. package/dist/suiteApps.d.ts +66 -0
  38. package/dist/suiteApps.d.ts.map +1 -0
  39. package/dist/suiteApps.js +168 -0
  40. package/dist/suiteApps.js.map +1 -0
  41. package/dist/types.d.ts +1 -1
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/unisimMark.d.ts +1 -1
  44. package/dist/unisimMark.d.ts.map +1 -1
  45. package/dist/unisimMark.js +8 -5
  46. package/dist/unisimMark.js.map +1 -1
  47. package/ios/Sources/UnisimSuiteAuthPlugin/UnisimSuiteAuthPlugin.swift +63 -1
  48. package/package.json +8 -2
package/README.md CHANGED
@@ -126,7 +126,7 @@ not configure this beyond passing `cookieDomain`:
126
126
  | --- | --- | --- |
127
127
  | Browser on `*.unisim.co.uk` | cookie scoped to the parent zone | every suite subdomain |
128
128
  | Browser on localhost / Electron | localStorage | nothing (origin-scoped) |
129
- | Native (Capacitor), plugin present | shared iOS Keychain group | **every suite app on the device** |
129
+ | Native (Capacitor), plugin present | shared suite store (see below) | **every suite app on the device** |
130
130
  | Native, plugin missing | localStorage | nothing, but it does persist |
131
131
 
132
132
  ⚠️ **A Capacitor app cannot use the cookie.** It runs at `capacitor://localhost`,
@@ -137,11 +137,56 @@ in the native bundle too, so before 0.123.0 **the native builds could not
137
137
  persist a session at all**: sign in, force-quit, signed out again, nothing
138
138
  logged. `npm run test:session-storage` pins that behaviour.
139
139
 
140
- ### Turning on the shared Keychain for a native app
140
+ ⚠️ **A shared store does not by itself give you a shared SIGN-OUT.** supabase-js
141
+ announces a session it finds on resume, and announces nothing when it finds the
142
+ store emptied — so an app already running keeps showing a signed-in UI after you
143
+ sign out somewhere else. `UniversalProvider` closes that by re-reading the store
144
+ on every resume; `npm run test:shared-signout` pins both legs.
141
145
 
142
- The SDK ships the native half itself (`ios/Sources/UnisimSuiteAuthPlugin`), so
143
- `npx cap sync ios` picks it up with no Xcode project surgery. The one thing it
144
- cannot do for you is the entitlement:
146
+ ### The shared store is two mechanisms behind one name
147
+
148
+ `UnisimSuiteAuth` presents the same three calls on both platforms, but what is
149
+ underneath could hardly be less alike, and the difference decides what can go
150
+ wrong:
151
+
152
+ | | iOS | Android |
153
+ | --- | --- | --- |
154
+ | Mechanism | one Keychain access group | a ring of ContentProviders, one per app |
155
+ | Shared by | the OS, for apps of the same **team** | the apps themselves, gated on a `signature` permission |
156
+ | Written on sign-in | once | to this app, then pushed to every installed peer |
157
+ | Fails to share when | the entitlement is missing | the peer list is empty, or signatures differ |
158
+
159
+ Android has no shared box to put anything in — `sharedUserId` was deprecated in
160
+ API 29 and is unusable for anything new — so there is nowhere central to write.
161
+ Each app therefore hosts `SuiteAuthProvider` behind
162
+ `uk.co.unisim.suite.permission.SUITE_AUTH` (protection level `signature`, so only
163
+ same-key apps get through), and reads and writes fan out across the installed
164
+ peers. Sources: `android/src/main/java/uk/co/unisim/sdk/`.
165
+
166
+ ⚠️ **On Android the peer list is the thing that breaks.** API 30+ hides packages
167
+ you have not declared an interest in, so the `<queries>` block in the module's
168
+ `AndroidManifest.xml` is load-bearing: without an app listed there it is
169
+ invisible, keeps a private session, and nothing reports a problem. **Its
170
+ `<provider>` authorities are the single canonical list of participating suite
171
+ apps** — the Java side keeps no second copy, it filters what the package manager
172
+ admits to by the `.unisimsuiteauth` suffix. Adding an app to the suite means
173
+ adding one line there. (The `<package>` list below it is a different list for a
174
+ different job — see the switcher section.)
175
+
176
+ ⚠️ **Same key, in practice, means the same BUILD TYPE.** Debug builds are signed
177
+ with `~/.android/debug.keystore` and release builds with the upload key. Install
178
+ a mix on one phone and the platform treats them as two different vendors: access
179
+ is refused, every app keeps its own session, and a refused peer is a normal
180
+ enough thing to meet that nothing shouts. Test all-debug or all-release.
181
+
182
+ ### Turning on the shared store for a native app
183
+
184
+ The SDK ships both native halves itself (`ios/Sources/UnisimSuiteAuthPlugin` and
185
+ `android/`), so `npx cap sync` picks them up with no Xcode or Gradle surgery.
186
+ **Android needs nothing further** — the permission, the `<queries>` list and the
187
+ provider all arrive through manifest merging.
188
+
189
+ iOS has one thing the SDK cannot do for you, the entitlement:
145
190
 
146
191
  ```
147
192
  node ../universal-platform/scripts/add-suite-keychain.mjs <path-to-app-repo>
@@ -156,11 +201,63 @@ call returns `errSecMissingEntitlement` (-34018), not just the shared ones. An
156
201
  unsigned simulator build (`CODE_SIGNING_ALLOWED=NO`) gets the same, which is
157
202
  worth knowing before you go hunting for a bug in the plugin.
158
203
 
159
- `hasSharedKeychain()` and `chooseSessionStorage().kind` report which store an
204
+ `hasSharedSuiteStore()` (and `chooseSessionStorage().kind`) report which store an
160
205
  app actually got — a build that quietly landed on `native-local` still signs in,
161
206
  it just does not carry to the other apps, and nothing about the behaviour makes
162
207
  that visible.
163
208
 
209
+ ## Opening a suite app instead of its website
210
+
211
+ On a phone, a switcher row for a product you have installed opens **the app**,
212
+ and says so with an `Installed` badge. In a browser nothing changes at all.
213
+
214
+ ⚠️ **An https link cannot do this, and adding associated domains would not
215
+ help.** A WKWebView does not honour universal links for navigation that happens
216
+ inside it, so a Capacitor app tapping `https://opensource.unisim.co.uk/pdf`
217
+ loads the website in the webview it is already in, however the domains are
218
+ configured. Handing off needs the platform's app-to-app channel: a custom URL
219
+ scheme on iOS, a launch intent on Android.
220
+
221
+ `SUITE_NATIVE_APPS` in `src/suiteApps.ts` is the source list — id, scheme, iOS
222
+ bundle id, Android package. A product missing from it (Ergo Assess, Exports,
223
+ anything web-only) keeps today's link, which is the right answer for a product
224
+ with no app.
225
+
226
+ | | iOS | Android |
227
+ | --- | --- | --- |
228
+ | Matches on | the app's custom URL **scheme** | the app's **package** |
229
+ | Asked with | `canOpenURL` / `UIApplication.open` | `getLaunchIntentForPackage` |
230
+ | Needs, per app | `CFBundleURLTypes` **and** every other app's scheme in `LSApplicationQueriesSchemes` | nothing — the SDK's manifest merges in |
231
+ | Wrong list looks like | "not installed" | "not installed" |
232
+
233
+ ⚠️ **Both platforms fail the same silent way, and it is the reason this is
234
+ tested.** An undeclared scheme or package is not an error: it is the ordinary
235
+ answer for an app you do not have. A drifted list does not break the switcher,
236
+ it just quietly stops offering one product on one platform. `npm run
237
+ test:suite-apps` pins the registry against the Android manifest and against the
238
+ switcher's own catalogue, and pins the browser case to today's behaviour.
239
+
240
+ The iOS half is per app and generated, never hand-edited:
241
+
242
+ ```
243
+ node ../universal-platform/scripts/add-suite-app-links.mjs <path-to-app-repo>
244
+ ```
245
+
246
+ Add `--check` to fail instead of write. Then `npx cap sync ios` and **rebuild** —
247
+ `Info.plist` is compiled into the bundle, so running the script is not shipping
248
+ the change.
249
+
250
+ ⚠️ Adding a new native app means three edits, not one: the registry, the
251
+ `<package>` list in `android/src/main/AndroidManifest.xml`, and a run of the
252
+ script above in every app repo (they all need the new scheme in their queries
253
+ list, not just the new app).
254
+
255
+ The row stays a real `<a href>` throughout — the handoff is a click handler over
256
+ the top, so a launch that fails still navigates and long-press/copy-link keep
257
+ working. `openSuiteApp()` resolving `false` is the fallback signal, not an
258
+ error. A `comingSoon` product never launches even when installed, because its
259
+ row does not navigate either.
260
+
164
261
  ## Multi-tenant model
165
262
 
166
263
  This SDK is the client side of a Supabase-backed multi-tenant schema (see `universal-platform/supabase/migrations`). Every read/write is scoped to the user's active org via the `is_org_member()` helper in RLS policies. Anonymous-auth users get the same hooks; trial caps (3 people / 1 team / 2 places / 1 project) are enforced server-side by the `enforce_anonymous_trial_caps()` trigger.
@@ -0,0 +1,59 @@
1
+ // The native Android half of @unisim/sdk — a Capacitor plugin module that
2
+ // `npx cap sync android` wires into every app already depending on the SDK, the
3
+ // same way UnisimSdk.podspec does on iOS. Nothing to publish separately and
4
+ // nothing to version in lockstep.
5
+ //
6
+ // ⚠️ **Java, not Kotlin, on purpose.** A Kotlin module obliges every consuming
7
+ // app's ROOT build.gradle to carry the kotlin-gradle-plugin on its buildscript
8
+ // classpath, and Capacitor's Android template does not. That would be fourteen
9
+ // generated projects to hand-edit, and one forgotten edit is a build failure
10
+ // far from its cause. There is no Kotlin here worth that.
11
+ //
12
+ // Every version is read from the host app's variables.gradle when it defines
13
+ // one (Capacitor's template always does) and falls back to the template's own
14
+ // values otherwise, so this module never drags an app off its chosen SDK level.
15
+
16
+ ext {
17
+ androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
18
+ }
19
+
20
+ buildscript {
21
+ repositories {
22
+ google()
23
+ mavenCentral()
24
+ }
25
+ dependencies {
26
+ classpath 'com.android.tools.build:gradle:8.7.2'
27
+ }
28
+ }
29
+
30
+ apply plugin: 'com.android.library'
31
+
32
+ android {
33
+ namespace "uk.co.unisim.sdk"
34
+ compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
35
+ defaultConfig {
36
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
37
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
38
+ versionCode 1
39
+ versionName "1.0"
40
+ }
41
+ lintOptions {
42
+ abortOnError false
43
+ }
44
+ compileOptions {
45
+ sourceCompatibility JavaVersion.VERSION_17
46
+ targetCompatibility JavaVersion.VERSION_17
47
+ }
48
+ }
49
+
50
+ repositories {
51
+ google()
52
+ mavenCentral()
53
+ }
54
+
55
+ dependencies {
56
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
57
+ implementation project(':capacitor-android')
58
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
59
+ }
@@ -0,0 +1,88 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ Merged into every suite app that depends on @unisim/sdk. Three things live
4
+ here, and each one is load-bearing:
5
+
6
+ 1. The PERMISSION, at protection level `signature`. This is what makes the
7
+ shared session store safe: the platform will only let a caller through if
8
+ it is signed with the same key. Every suite app declares it, which is
9
+ fine precisely because they share a signing key — two apps declaring the
10
+ same permission name under DIFFERENT keys is an install failure
11
+ (INSTALL_FAILED_DUPLICATE_PERMISSION), and that is the protection
12
+ working, not a bug to route around.
13
+
14
+ 2. The QUERIES block, which is really TWO lists for two different jobs. On
15
+ API 30+ a package this app has not declared an interest in simply does
16
+ not exist as far as the package manager is concerned, and BOTH failures
17
+ are silent — nothing throws, the answer just comes back empty.
18
+
19
+ 2a. The PROVIDER authorities: who this app shares a SESSION with.
20
+ Without this list SuiteAuthStore.peerAuthorities() returns empty, every
21
+ app keeps a private session, and NOTHING REPORTS A PROBLEM — sign-in
22
+ just quietly stops travelling.
23
+
24
+ ⚠️ This is the one canonical list of participating suite apps. The Java
25
+ side does not keep a second copy; it filters what the package manager
26
+ admits to by the `.unisimsuiteauth` suffix. Adding an app to the suite
27
+ means adding one line HERE and nowhere else.
28
+
29
+ Two Capacitor apps are deliberately absent, and neither is an oversight:
30
+ Universal AI and Universal Family both run their own Supabase client on
31
+ their own storage key by an explicit decision recorded in their source,
32
+ so they have no shared session to take part in.
33
+
34
+ 2b. The PACKAGE list: who the suite switcher can OPEN, via
35
+ UnisimSuiteAuthPlugin.installedApps/openApp. Deliberately a separate
36
+ list, because it answers a different question and gets a different
37
+ answer: Universal AI is here and not in 2a — being outside the shared
38
+ session says nothing about whether the switcher should offer to open it.
39
+ Universal Family is in neither, for the third reason again: it has no row
40
+ in the switcher to open. `<package>` (not `<provider>`) because
41
+ getLaunchIntentForPackage needs the package itself visible, which a
42
+ provider query only grants for the app hosting that provider.
43
+
44
+ ⚠️ Mirrors SUITE_NATIVE_APPS in `src/suiteApps.ts`, which cannot be
45
+ imported into a manifest. `tests/suite-apps.mjs` fails if the two drift.
46
+
47
+ 3. The PROVIDER, whose authority is built from ${applicationId} so each app
48
+ ends up with its own. They cannot share one authority — two apps claiming
49
+ the same one cannot be installed side by side.
50
+ -->
51
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
52
+
53
+ <permission
54
+ android:name="uk.co.unisim.suite.permission.SUITE_AUTH"
55
+ android:label="Universal Suite shared sign-in"
56
+ android:protectionLevel="signature" />
57
+
58
+ <uses-permission android:name="uk.co.unisim.suite.permission.SUITE_AUTH" />
59
+
60
+ <queries>
61
+ <provider
62
+ android:authorities="uk.co.unisim.coshhassess.unisimsuiteauth;uk.co.unisim.cyberassess.unisimsuiteauth;uk.co.unisim.fireassess.unisimsuiteauth;uk.co.unisim.riskassess.unisimsuiteauth;uk.co.unisim.wfhassess.unisimsuiteauth;uk.co.unisim.blackbook.unisimsuiteauth;uk.co.unisim.compress.unisimsuiteauth;uk.co.unisim.converter.unisimsuiteauth;uk.co.unisim.images.unisimsuiteauth;uk.co.unisim.pdf.unisimsuiteauth;uk.co.unisim.polling.unisimsuiteauth;uk.co.unisim.qr.unisimsuiteauth" />
63
+
64
+ <package android:name="uk.co.unisim.cyberassess" />
65
+ <package android:name="uk.co.unisim.wfhassess" />
66
+ <package android:name="uk.co.unisim.fireassess" />
67
+ <package android:name="uk.co.unisim.coshhassess" />
68
+ <package android:name="uk.co.unisim.riskassess" />
69
+ <package android:name="uk.co.unisim.pdf" />
70
+ <package android:name="uk.co.unisim.images" />
71
+ <package android:name="uk.co.unisim.qr" />
72
+ <package android:name="uk.co.unisim.compress" />
73
+ <package android:name="uk.co.unisim.converter" />
74
+ <package android:name="uk.co.unisim.polling" />
75
+ <package android:name="uk.co.unisim.blackbook" />
76
+ <package android:name="uk.co.unisim.ai" />
77
+ </queries>
78
+
79
+ <application>
80
+ <provider
81
+ android:name="uk.co.unisim.sdk.SuiteAuthProvider"
82
+ android:authorities="${applicationId}.unisimsuiteauth"
83
+ android:exported="true"
84
+ android:grantUriPermissions="false"
85
+ android:permission="uk.co.unisim.suite.permission.SUITE_AUTH" />
86
+ </application>
87
+
88
+ </manifest>
@@ -0,0 +1,107 @@
1
+ package uk.co.unisim.sdk;
2
+
3
+ import android.content.ContentProvider;
4
+ import android.content.ContentValues;
5
+ import android.content.Context;
6
+ import android.database.Cursor;
7
+ import android.net.Uri;
8
+ import android.os.Bundle;
9
+
10
+ import androidx.annotation.NonNull;
11
+ import androidx.annotation.Nullable;
12
+
13
+ /**
14
+ * This app's window into its own session store, opened only to the other suite
15
+ * apps.
16
+ *
17
+ * <p>Declared in this module's manifest with
18
+ * {@code android:authorities="${applicationId}.unisimsuiteauth"} and
19
+ * {@code android:permission="uk.co.unisim.suite.permission.SUITE_AUTH"}, a
20
+ * permission whose protection level is {@code signature} — so the only callers
21
+ * the platform will let through are apps signed with the same key. That is the
22
+ * Android analogue of the iOS Keychain group's "same team" rule, and it is
23
+ * enforced by the OS rather than by anything here.
24
+ *
25
+ * <p>⚠️ <b>Same key means the same BUILD TYPE, in practice.</b> A debug build is
26
+ * signed with {@code ~/.android/debug.keystore} and a release build with the
27
+ * upload key; install a mix on one phone and they are, to the platform, apps
28
+ * from two different vendors. Access is refused, each app keeps its own session,
29
+ * and — because a refused peer is a normal thing to meet — nothing shouts about
30
+ * it. Test the suite all-debug or all-release, never half of each.
31
+ *
32
+ * <p>This deliberately uses {@link #call} rather than the table-shaped
33
+ * query/insert/delete surface. There is no table here: it is three operations on
34
+ * one key, and modelling that as a {@link Cursor} would mean inventing a schema
35
+ * for both sides to agree about.
36
+ */
37
+ public class SuiteAuthProvider extends ContentProvider {
38
+
39
+ @Override
40
+ public boolean onCreate() {
41
+ return true;
42
+ }
43
+
44
+ @Nullable
45
+ @Override
46
+ public Bundle call(@NonNull String method, @Nullable String arg, @Nullable Bundle extras) {
47
+ Context ctx = getContext();
48
+ if (ctx == null || arg == null) return null;
49
+
50
+ switch (method) {
51
+ case SuiteAuthStore.METHOD_GET: {
52
+ Bundle out = new Bundle();
53
+ // Deliberately the LOCAL read, not SuiteAuthStore.read(): a peer
54
+ // asking us must get our own answer. Fanning out from inside a
55
+ // fan-out is how three apps with nothing stored spend a round of
56
+ // IPC asking each other the same question.
57
+ out.putString(SuiteAuthStore.EXTRA_VALUE, SuiteAuthStore.readLocal(ctx, arg));
58
+ return out;
59
+ }
60
+ case SuiteAuthStore.METHOD_SET: {
61
+ if (extras == null) return null;
62
+ String value = extras.getString(SuiteAuthStore.EXTRA_VALUE);
63
+ if (value == null) return null;
64
+ SuiteAuthStore.writeLocal(ctx, arg, value);
65
+ return Bundle.EMPTY;
66
+ }
67
+ case SuiteAuthStore.METHOD_REMOVE: {
68
+ SuiteAuthStore.deleteLocal(ctx, arg);
69
+ return Bundle.EMPTY;
70
+ }
71
+ default:
72
+ return null;
73
+ }
74
+ }
75
+
76
+ // ── The table surface, unused ────────────────────────────────────────────
77
+
78
+ @Nullable
79
+ @Override
80
+ public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection,
81
+ @Nullable String[] selectionArgs, @Nullable String sortOrder) {
82
+ return null;
83
+ }
84
+
85
+ @Nullable
86
+ @Override
87
+ public String getType(@NonNull Uri uri) {
88
+ return null;
89
+ }
90
+
91
+ @Nullable
92
+ @Override
93
+ public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
94
+ return null;
95
+ }
96
+
97
+ @Override
98
+ public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
99
+ return 0;
100
+ }
101
+
102
+ @Override
103
+ public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection,
104
+ @Nullable String[] selectionArgs) {
105
+ return 0;
106
+ }
107
+ }
@@ -0,0 +1,179 @@
1
+ package uk.co.unisim.sdk;
2
+
3
+ import android.content.ContentProviderClient;
4
+ import android.content.ContentResolver;
5
+ import android.content.Context;
6
+ import android.content.SharedPreferences;
7
+ import android.content.pm.PackageInfo;
8
+ import android.content.pm.PackageManager;
9
+ import android.content.pm.ProviderInfo;
10
+ import android.net.Uri;
11
+ import android.os.Bundle;
12
+ import android.util.Log;
13
+
14
+ import java.util.ArrayList;
15
+ import java.util.List;
16
+
17
+ /**
18
+ * The suite's shared session store on Android.
19
+ *
20
+ * <p>iOS gets this for free: a Keychain access group is one box that every app
21
+ * signed by the same team opens. <b>Android has no such box.</b> The old answer,
22
+ * {@code android:sharedUserId}, was deprecated in API 29 and cannot be used by
23
+ * anything new. So the shared store here is not a place — it is an agreement
24
+ * between peers:
25
+ *
26
+ * <ul>
27
+ * <li>every suite app hosts a {@link SuiteAuthProvider}, guarded by a
28
+ * {@code signature}-level permission, so only apps signed with the same
29
+ * key can reach it;</li>
30
+ * <li>a <b>write</b> goes to this app's own prefs and is pushed out to every
31
+ * installed peer;</li>
32
+ * <li>a <b>read</b> answers from this app's own prefs, and only asks the peers
33
+ * when it has nothing — which is what makes a freshly-installed app pick
34
+ * up a session that was created before it existed;</li>
35
+ * <li>a <b>delete</b> goes everywhere, because that is sign-out.</li>
36
+ * </ul>
37
+ *
38
+ * <p>⚠️ <b>The fan-out is the whole mechanism, and the delete leg is the half
39
+ * that is easy to forget.</b> Writing to peers but deleting only locally gives
40
+ * you a suite where signing IN travels and signing OUT does not — which is
41
+ * exactly the bug the web and iOS sides already had for a different reason (see
42
+ * the resume resync in {@code provider.tsx}). Both legs, or neither.
43
+ *
44
+ * <p>⚠️ <b>Peer visibility is not automatic on API 30+.</b> {@code
45
+ * getInstalledPackages} only returns packages this app is allowed to see, so the
46
+ * {@code <queries>} block in this module's manifest is load-bearing: without it
47
+ * the peer list comes back empty, every app keeps its own session, and nothing
48
+ * anywhere reports a problem. That manifest list is the ONE canonical list of
49
+ * suite apps — this class derives the peers from what it can actually see rather
50
+ * than carrying a second copy to drift out of step.
51
+ */
52
+ final class SuiteAuthStore {
53
+
54
+ private static final String TAG = "UnisimSuiteAuth";
55
+
56
+ /** Prefs file, private to each app; only ever reached across apps via the provider. */
57
+ private static final String PREFS = "unisim_suite_auth";
58
+
59
+ /**
60
+ * Every suite provider's authority is its applicationId plus this. It is also
61
+ * how a peer is recognised — see {@link #peerAuthorities}.
62
+ */
63
+ static final String AUTHORITY_SUFFIX = ".unisimsuiteauth";
64
+
65
+ static final String METHOD_GET = "get";
66
+ static final String METHOD_SET = "set";
67
+ static final String METHOD_REMOVE = "remove";
68
+ static final String EXTRA_VALUE = "value";
69
+
70
+ private SuiteAuthStore() {}
71
+
72
+ // ── This app's own copy ──────────────────────────────────────────────────
73
+
74
+ private static SharedPreferences prefs(Context ctx) {
75
+ return ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
76
+ }
77
+
78
+ static String readLocal(Context ctx, String key) {
79
+ return prefs(ctx).getString(key, null);
80
+ }
81
+
82
+ static void writeLocal(Context ctx, String key, String value) {
83
+ prefs(ctx).edit().putString(key, value).apply();
84
+ }
85
+
86
+ static void deleteLocal(Context ctx, String key) {
87
+ prefs(ctx).edit().remove(key).apply();
88
+ }
89
+
90
+ // ── The peers ────────────────────────────────────────────────────────────
91
+
92
+ static String ownAuthority(Context ctx) {
93
+ return ctx.getPackageName() + AUTHORITY_SUFFIX;
94
+ }
95
+
96
+ /**
97
+ * Every OTHER installed suite app that hosts a provider we can talk to.
98
+ *
99
+ * <p>Derived from what the package manager will admit to, which the manifest
100
+ * {@code <queries>} block is what makes non-empty on API 30+.
101
+ */
102
+ static List<String> peerAuthorities(Context ctx) {
103
+ List<String> out = new ArrayList<>();
104
+ String mine = ownAuthority(ctx);
105
+ PackageManager pm = ctx.getPackageManager();
106
+ List<PackageInfo> installed;
107
+ try {
108
+ installed = pm.getInstalledPackages(PackageManager.GET_PROVIDERS);
109
+ } catch (Exception e) {
110
+ Log.w(TAG, "could not enumerate packages; session will be app-local", e);
111
+ return out;
112
+ }
113
+ for (PackageInfo info : installed) {
114
+ if (info.providers == null) continue;
115
+ for (ProviderInfo provider : info.providers) {
116
+ if (provider.authority == null) continue;
117
+ if (!provider.authority.endsWith(AUTHORITY_SUFFIX)) continue;
118
+ if (provider.authority.equals(mine)) continue;
119
+ out.add(provider.authority);
120
+ }
121
+ }
122
+ return out;
123
+ }
124
+
125
+ private static Bundle callPeer(Context ctx, String authority, String method, String key, Bundle extras) {
126
+ Uri uri = new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT).authority(authority).build();
127
+ ContentProviderClient client = null;
128
+ try {
129
+ client = ctx.getContentResolver().acquireUnstableContentProviderClient(uri);
130
+ if (client == null) return null;
131
+ return client.call(method, key, extras);
132
+ } catch (Exception e) {
133
+ // A peer that is mid-update, force-stopped, or signed with a different
134
+ // key is a normal thing to meet. It must degrade to "that one didn't
135
+ // answer", never take the caller down.
136
+ Log.w(TAG, "peer " + authority + " did not answer " + method, e);
137
+ return null;
138
+ } finally {
139
+ if (client != null) client.close();
140
+ }
141
+ }
142
+
143
+ // ── The three operations, as the suite sees them ─────────────────────────
144
+
145
+ static String read(Context ctx, String key) {
146
+ String local = readLocal(ctx, key);
147
+ if (local != null) return local;
148
+
149
+ // Nothing of our own: this app may have been installed after the user
150
+ // signed in elsewhere. Ask around, and keep the first real answer so the
151
+ // next read is local.
152
+ for (String authority : peerAuthorities(ctx)) {
153
+ Bundle result = callPeer(ctx, authority, METHOD_GET, key, null);
154
+ if (result == null) continue;
155
+ String value = result.getString(EXTRA_VALUE);
156
+ if (value != null) {
157
+ writeLocal(ctx, key, value);
158
+ return value;
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+
164
+ static void write(Context ctx, String key, String value) {
165
+ writeLocal(ctx, key, value);
166
+ Bundle extras = new Bundle();
167
+ extras.putString(EXTRA_VALUE, value);
168
+ for (String authority : peerAuthorities(ctx)) {
169
+ callPeer(ctx, authority, METHOD_SET, key, extras);
170
+ }
171
+ }
172
+
173
+ static void delete(Context ctx, String key) {
174
+ deleteLocal(ctx, key);
175
+ for (String authority : peerAuthorities(ctx)) {
176
+ callPeer(ctx, authority, METHOD_REMOVE, key, null);
177
+ }
178
+ }
179
+ }