@unisim/sdk 0.123.3 → 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.
- package/README.md +103 -6
- package/android/build.gradle +59 -0
- package/android/src/main/AndroidManifest.xml +88 -0
- package/android/src/main/java/uk/co/unisim/sdk/SuiteAuthProvider.java +107 -0
- package/android/src/main/java/uk/co/unisim/sdk/SuiteAuthStore.java +179 -0
- package/android/src/main/java/uk/co/unisim/sdk/UnisimSuiteAuthPlugin.java +214 -0
- package/dist/ChangelogMenu.js +7 -0
- package/dist/ChangelogMenu.js.map +1 -1
- package/dist/SuiteSwitcher.d.ts.map +1 -1
- package/dist/SuiteSwitcher.js +70 -4
- package/dist/SuiteSwitcher.js.map +1 -1
- package/dist/UniversalAppsNavBar.d.ts.map +1 -1
- package/dist/UniversalAppsNavBar.js +101 -9
- package/dist/UniversalAppsNavBar.js.map +1 -1
- package/dist/UniversalNavBar.d.ts +25 -3
- package/dist/UniversalNavBar.d.ts.map +1 -1
- package/dist/UniversalNavBar.js +144 -61
- package/dist/UniversalNavBar.js.map +1 -1
- package/dist/UserProfile.d.ts +108 -1
- package/dist/UserProfile.d.ts.map +1 -1
- package/dist/UserProfile.js +147 -78
- package/dist/UserProfile.js.map +1 -1
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +7 -0
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +94 -1
- package/dist/provider.js.map +1 -1
- package/dist/sessionStorage.d.ts +25 -7
- package/dist/sessionStorage.d.ts.map +1 -1
- package/dist/sessionStorage.js +49 -15
- package/dist/sessionStorage.js.map +1 -1
- package/dist/suiteApps.d.ts +66 -0
- package/dist/suiteApps.d.ts.map +1 -0
- package/dist/suiteApps.js +168 -0
- package/dist/suiteApps.js.map +1 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/unisimMark.d.ts +1 -1
- package/dist/unisimMark.d.ts.map +1 -1
- package/dist/unisimMark.js +8 -5
- package/dist/unisimMark.js.map +1 -1
- package/ios/Sources/UnisimSuiteAuthPlugin/UnisimSuiteAuthPlugin.swift +63 -1
- 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
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
`
|
|
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
|
+
}
|