Skip to content

Feat/multiinstance ios - #218

Merged
Xyzcancer merged 10 commits into
masterfrom
feat/multiinstance-ios
Aug 13, 2026
Merged

Feat/multiinstance ios#218
Xyzcancer merged 10 commits into
masterfrom
feat/multiinstance-ios

Conversation

@Xyzcancer

Copy link
Copy Markdown
Contributor

No description provided.

Xyzcancer added 10 commits July 29, 2026 14:58
Add a process-wide SdkRegistry that tracks PersonalizationSDK instances by
shop_id, alongside pure InstanceResolver / PushTargetResolver decision logic
and a Cancellable subscription handle. createPersonalizationSDK now registers
each instance; deleteUserCredentials and a new deinit unregister it. Instances
are held weakly, so a host-released SDK drops out of the registry on
deallocation.

No public API is added and single-instance behaviour is unchanged.

Cover the registry and both resolvers with unit tests in REES46Tests (28 tests),
including the weak-reference auto-drop.
…itory

Route device_id / seance / IDFA reads and writes through a new
UserIdentityRepository port (UserDefaults-backed) instead of poking
UserDefaults.standard directly across SimplePersonalizationSDK. Each shop gets
its own UserDefaults suite (personalization_sdk_<shopId>), so two shops in one
app no longer share identity. The push token stays device-global.

A one-time migration adopts the pre-partition identity from the shared domain
into the first shop's partition (consume-once), so an existing install keeps
its did with no re-registration; a host that supplies a custom storage key is
used verbatim with no migration.

Cover partition naming, the migration guard, the repository, and multi-instance
isolation with unit tests.
Key the keychain backup of the init secret by shop (account = "<bundleId>.<shopId>")
instead of the bundle id alone, so two shops in one app no longer share the item
and a reinstall restores each shop's own did. A one-time adoption copies the
pre-partition item into the first shop's slot and then removes it, so an existing
install keeps its did across a reinstall without a second shop cloning it.

Route keychain read/write through a KeychainInitStore port, and clear it in
deleteUserCredentials (previously the backup outlived a credentials wipe and
resurrected the deleted did).

Cover the account naming and the store (round-trip, two-shop isolation, legacy
adoption) with unit tests.
Resolve the session id through a new SessionManager (injectable Clock and id
generator): reuse the persisted seance while last activity is within 2 hours
(the window rolls forward), otherwise start a fresh 10-character alphanumeric
one. The activity anchor (seanceLastActTime) lives in the per-shop identity
partition, so the session survives cold starts and stays isolated per shop —
aligning iOS with the Android and React Native SDKs.

The session is now client-owned: the server-assigned seance is no longer
adopted, so continuity no longer depends on the backend echoing it. The device
id is unchanged.

Cover reuse / expiry / rolling refresh / generation and the timestamp round-trip
with unit tests.
…aking upgrade

Route viewed slides, downloaded media, cart and favourites through a per-shop
LocalStateRepository instead of the shared UserDefaults.standard, so two shops in
one app no longer share or overwrite each other's stories state. shopId is threaded
through StoryContent -> Story -> Slide (slide ids are not unique across shops), and
the reset* methods delegate to the repository, so constructing one shop no longer
wipes another shop's viewed state.

Add an SDK-level upgrade test that seeds a pre-partition install's identity in the
real .standard domain and keychain and drives it through the production
UserIdentityRepositoryImpl(shopId:) / KeychainInitStoreImpl(shopId:service:)
constructors, proving an existing install keeps its did/seance/IDFA/keychain blob
(no silent re-registration) and a second shop never clones it.
Add the unified entry point — Rees46.initialize / register(shops:) /
instance(for:) / isInitialized / awaitInstance — backed by Rees46Config and a
Swift Rees46Error enum (unknownShopId / ambiguousShop). Resolution reuses the
internal registry and InstanceResolver; shops can be registered lazily and
materialize on first use.

Extract makeRegisteredSDK as the single construction path (build, reset the
per-shop cache, register) shared by the facade and createPersonalizationSDK, and
thread an optional storageKey through it. Deprecate createPersonalizationSDK in
favour of the facade — it still works and returns the same type.

Add a StoriesView.configure(shopId:) overload that resolves its instance
reactively via awaitInstance, and make Cancellable public so the facade can hand
it back. Cover the facade with Rees46FacadeTests: resolution, the error contract,
lazy registration, and await/cancel.
Add Rees46.handlePush(_:event:): resolve the target shop from the payload's
shop_id via PushTargetResolver, then track received/clicked against that instance.
A push for an unknown shop — or with no shop_id while several shops are live — is
dropped rather than tracked against the wrong one; a single-instance app still
resolves. Navigation stays with the host; handlePush only tracks.

Add PushEvent (received/clicked) and a pure PushPayloadParser for shop_id and
(type, code); NotificationService.extractTypeAndCode now delegates to it.

Make the push-token upload throttle per shop (mainPushTokenLastUploadDateKey
.<shopId>) so one shop's upload no longer throttles the others — each initialized
instance registers the device token with its own backend.

Cover routing and parsing with HandlePushRoutingTests and PushPayloadParserTests.
NotificationService tracked delivered/received/clicked against the single sdk it
captured at init, so in a multi-shop app every push was attributed to the wrong
shop. Route all three through Rees46.handlePush, which resolves the target by the
payload's shop_id; navigation, token registration and notification categories stay
in the service. Add PushEvent.delivered so the delivery beacon is routed too.

The service no longer drives notificationTrackerDelegate (handlePush does the
tracking now, so calling it would double-track); NotificationTracker is kept for
source compatibility but is inert. Single-shop behaviour is unchanged — handlePush
resolves the one live shop, the same instance the service captured.

Extend HandlePushRoutingTests with the delivered case.
Add MultiInstanceViewController: two shops live in one app, each resolved by
shopId through Rees46.instance(for:) with no globalSDK — session cards, fail-fast
contract buttons, push-routing buttons, per-shop stories via the StoriesView
shopId overload, and a log. Reachable from a button on the main screen, whose DID
label now reads getDeviceId() instead of the pre-partition UserDefaults.standard
key.

Add MultiInstanceE2ETest against the live backend: two shops resolve to distinct
instances, the default is ambiguous, an unknown shop throws, and the two shops
keep isolated sessions (distinct client seances; the did is device-level). Point
the tests at the real test shop — the previous default was deactivated and the
backend returns "shop not found" for it — and add a second real shop.

Update DeviceIdSaveTest to assert the did persists in the shop partition, not the
shared UserDefaults.standard key.
- Rees46.handlePush: resolve via InstanceResolver (live + pending) and track a
  registered-but-not-initialized shop's push standalone (PushDeliveryTracker)
  instead of dropping it
- remove the now-unused PushTargetResolver and its tests
- SdkRegistry: fix a stale routing comment
- tests: pending-push routing, upgrade migration
@Xyzcancer
Xyzcancer merged commit 6f9d7da into master Aug 13, 2026
2 checks passed
@Xyzcancer
Xyzcancer deleted the feat/multiinstance-ios branch August 13, 2026 06:58
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.

1 participant