Skip to content

[SDK-673] Add switchProject for runtime project switching - #1086

Open
joaodordio wants to merge 6 commits into
feature/SDK-675-offline-disable-devicefrom
feature/SDK-673-switch-project
Open

joaodordio wants to merge 6 commits into
feature/SDK-675-offline-disable-devicefrom
feature/SDK-673-switch-project

Conversation

@joaodordio

@joaodordio joaodordio commented Aug 25, 2026

Copy link
Copy Markdown
Member

📝 Summary

Adds IterableApi.switchProject(context, project, callback), moving a running app between Iterable projects in place with no restart and no state carried over.

🎟️ Jira Ticket: SDK-673

⚠️ Stacked PR. This targets feature/SDK-675-offline-disable-device, not master. Review #1085 first and merge it first. SDK-673 depends on it: the "queued device disables survive the switch" behaviour is inert without the offlineApiSet change in SDK-675, because a disableDevice is not offline-queueable without it. Re-target this to master once SDK-675 lands.

The iOS counterpart is iterable-swift-sdk#1091 (SDK-674). The two were written together and the behaviour is intended to match.

📖 Description

For multi-region apps that need to move between a US and an EU project without restarting. Previously the only reliable way to change projects was an app restart.

The call returns immediately and runs the whole sequence on the background executor: disable the push token on the previous project, reset the in-app, embedded and unknown-user managers, purge the offline queue apart from queued device disables, clear identity and the rest of the previous project's storage, then re-initialize against the new key. The auth manager is rebuilt against the new config and the request processor re-bound to it, which iOS gets for free from its instance swap.

The project pair

switchProject takes an IterableProject, which pairs a project's API key with the IterableConfig to run it with:

IterableProject euProject = new IterableProject(euApiKey, new IterableConfig.Builder()
        .setAuthHandler(euAuthHandler)
        .setDataRegion(IterableDataRegion.EU)
        .build());

The two halves are paired because IterableConfig carries the dataRegion and the IterableAuthHandler. Passing a loose key and config allowed one project's key to be combined with another project's region or auth handler, and that mismatch surfaces as an auth failure, or a request sent to the wrong region, that looks unrelated to the project it came from. Pairing them makes it unrepresentable.

The constructor rejects a null or blank key with IllegalArgumentException, so an unusable project is not constructible and a blank key can no longer reach a teardown. The switcher keeps its own blank-key guard anyway, because the package-private entry point is also reachable from initialize-time paths and from Kotlin callers whose platform types can carry a null through.

The (apiKey, config) form is now the package-private switcher entry point rather than public API. Replaced rather than overloaded, because this is unreleased: keeping both public would leave the loose form available forever and reintroduce exactly what the pair removes. iOS mirrors this with IterableProject and a failable initializer, so the guarantee matches while each platform keeps its own idiom for reporting an invalid value.

Callback contract

IterableProjectSwitchCallback is a single-method interface, onProjectSwitched(IterableProjectSwitchResult result), delivered on the main thread.

true means every teardown step completed cleanly. false means the SDK is on the new project but a cleanup step was noisy, or no device disable could be confirmed. false never means the switch failed or was rolled back. An app that does not use push always sees false, which is normal and not an error. The right response is the same either way: carry on and re-identify the user.

It is a single-method interface deliberately. Reusing IterableInitializationCallback does not work: its only abstract method takes no arguments, so a lambda would bind to that one and silently discard the boolean.

The result type

The callback reports an IterableProjectSwitchResult, either SWITCHED_CLEANLY or SWITCHED_WITH_WARNINGS, rather than a boolean.

The boolean was the problem the guide kept having to write around: false did not mean the switch failed, did not mean it was rolled back, was the expected outcome for any app without push, and called for exactly the same handling as true. A value needing that much explanation to avoid being misread is the wrong shape. Both case names lead with "switched" so the part that holds in either case, that the SDK is on the new project, is visible at the call site instead of being something the reader has to recall.

Internal plumbing still carries the teardown boolean the individual steps set, and converts once at the callback boundary via IterableProjectSwitchResult.from, so the change stays at the edge.

Two cases and no reason detail, deliberately. Java enums can gain constants later without breaking callers, so there is no cost to leaving room, and inventing a warning taxonomy before anyone has asked for one would be guesswork. If you want the noisy step identified in the result rather than only in the logs, say so on this PR.

Details worth a reviewer's attention

The previous project's device disable. The disable captures that project's API key and its region endpoint when it is initiated, because the FCM token lookup is asynchronous and the live key can change underneath it. Without the captured key the disable lands on the new project, leaving the old project still delivering push to the device. Without the captured endpoint a cross-region switch sends the old key to the new region and is rejected. The switch waits up to 2 seconds for the disable to reach the request layer before swapping; if it times out the switch still completes and reports false, and the disable still reaches the project it was created for.

Region binding for offline tasks. Offline tasks now persist the endpoint they were created for, so a rehydrated request goes to the region it was built for rather than whichever region is live at flush time. Tasks already on disk from an earlier version keep resolving the old way, so nothing queued is lost on upgrade.

trackPushOpen is not queued behind the switch gate. A push payload carries the sending project's campaignId, templateId and messageId, so replaying it after the switch would report it against a project where those IDs do not exist. It runs inline instead. This needed care because the switch gate and the background-init gate are the same state on Android, so queueOrExecuteUnlessSwitching splits them: still queued during initialization, inline during a switch. iOS does not gate push handling at all, for the same reason.

Gate consistency. The longest track and updateEmail overloads were public and ran inline while every shorter overload was queued, so mid-switch behaviour depended on which overload the caller used. Both now wrap private *Internal methods, matching the existing setEmail / setUserId treatment.

Per-project state on the shared instance. iOS drops this when it replaces its SDK instance; Android reuses sharedInstance, so it has to be explicit. The switch now clears the inbox session ID, stored push payload, notification data and device attributes. The inbox session ID was the one producing cross-project data: it would otherwise be attached to the new project's first in-app tracking call. Device ID and visitor consent are project-agnostic and deliberately kept.

Concurrency. The gate check and enqueue are a single atomic step, so a call cannot pass the check just before the gate is raised and then run against a half torn-down SDK. Twelve fields are now volatile and getAuthManager()'s rebuild is guarded by a lock. The switch also recovers if the background executor is shut down when the teardown or drain is submitted, which could happen when switchProject was called from inside a switch callback.

Guards. A null context or apiKey throws IllegalArgumentException, since both are @NonNull and a null is a programmer error. An empty or whitespace-only key is a runtime condition, so it is refused without tearing anything down and reported as false, matching iOS.

Known limitations, called out deliberately

  • Campaign-attributed events other than push opens are still queued. trackPurchase with a campaignId, and track with a campaignId, are replayed against the new project carrying the previous project's IDs if they are issued during the switch window. iOS queues its campaign-carrying trackPurchase too, so this is consistent across platforms rather than an Android-only gap. Fixing it properly means per-call key binding on both SDKs and belongs in its own ticket.
  • iOS has no in-flight-initialize guard, Android does. On iOS an initialize immediately followed by switchProject is torn down underneath. That divergence needs an iOS follow-up.
  • Neither SDK persists switch intent, so a process death mid switch leaves the app restarted with the previous project's identity cleared and no record a switch was attempted.

🧪 How to test?

759 tests, 0 failures, checkstyle clean.

  • IterableSwitchProjectTest, 43 tests: the guard cases, each teardown step, identity and storage clearing, manager rebuild, keychain rebuild, offline queue region binding, rapid and nested switches, a throwing teardown step, callback delivery and thread, blank keys, per-project instance state, and gate consistency for the longest overloads.
  • IterableSwitchProjectQueueDrainTest, 4 tests: a drain no executor will accept, a switch started off the main thread, and the two push-open cases (inline during a switch, still queued during initialization).
  • IterableSwitchProjectDisableRegionTest: the disable dispatch timeout path, now asserting the warnings result, driven with an overridden timeout so it does not burn real time.
  • IterableOfflineTaskRegionTest, 13 tests: persisted baseUrl, rehydrated task region, old-schema fallback, cross-region isolation, disable preservation.
  • IterablePushRegistrationTaskTest: the disable carries the captured key and endpoint rather than the live ones, and is sent before the key swap.

Every new test was checked to fail with its fix reverted, not just to pass alongside it.

Manual check: initialize against project A, identify a user, then call switchProject with project B's key from the callback of a region lookup. Confirm the device is disabled on A and registered on B, the inbox is empty immediately after, and no event reaches A after the callback.

🧾 Changelog

Added to CHANGELOG.md under Unreleased. One Added entry for switchProject with sub-bullets covering the callback contract, the queued and inline call sets, the state that is cleared and preserved, and the edge cases. Seven Fixed entries covering offline endpoint persistence, disable preservation across a switch, the overload gating, the in-flight-initialize deferral, executor recovery, the duplicate auth listener, and the atomic gate check.

📹 Loom recording if applicable

Not recorded.

🐞 Github Issues solved

None known.

📚 Docs PR if applicable

A docs PR is required and does not exist yet. This adds public API. There is an adoption guide written for the first customer team, which should be the basis for the iterable-docs entry, and it needs to cover the callback contract, the per-platform queued call sets, and the iOS in-flight-initialize caveat.


Note on the base: this stack sits on 5d726694 and origin/master has moved 4 commits ahead, including a 3.10.1 release prep and SDK-547, which touches JWT auth timing. Worth rebasing both branches onto latest master before merge, since SDK-547 is adjacent to the auth manager rebuild here.

Adds IterableApi.switchProject(context, apiKey, config, callback), which moves a
running app from one Iterable project to another in place, with no app restart and
no state from the previous project leaking into the new one. Aimed at multi-region
apps that need to move between a US and an EU project without restarting.

The call returns immediately and runs the whole sequence on the background
executor: disable the push token on the previous project, reset the in-app,
embedded and unknown-user managers, purge the offline queue apart from queued
device disables, clear identity and the rest of the previous project's storage,
then re-initialize against the new key. The auth manager is rebuilt against the new
config and the request processor re-bound to it, which iOS gets for free from its
instance swap.

Callback contract: IterableProjectSwitchCallback is a single-method interface so a
lambda receives the result. true means every teardown step completed cleanly, false
means the SDK is on the new project but a cleanup step was noisy or no device
disable could be confirmed. false never means the switch failed or was rolled back.
An app that does not use push always sees false, which is not an error.

Notable details:

- The previous project's disable captures that project's API key and its region
  endpoint when it is initiated, because the FCM token lookup is asynchronous and
  the live key can change underneath it. Without the captured key the disable lands
  on the new project; without the endpoint a cross-region switch sends the old key
  to the new region and is rejected. The switch waits up to 2 seconds for the
  disable to reach the request layer before swapping.
- Offline tasks now persist the endpoint they were created for, so a rehydrated
  request goes to the region it was built for instead of whichever region is live
  at flush time. Tasks already on disk keep resolving the old way.
- trackPushOpen is not queued behind the switch gate. A push payload carries the
  sending project's campaignId, templateId and messageId, so replaying it would
  report it against a project where those IDs do not exist. It runs inline instead.
  Initialization queueing is unaffected.
- The longest track and updateEmail overloads are now queued like their shorter
  siblings. They were public and ran inline, so mid-switch behaviour depended on
  which overload the caller happened to use.
- Per-project state held on the shared instance is cleared: inbox session ID, push
  payload, notification data and device attributes. iOS drops all of this when it
  replaces its instance; Android reuses sharedInstance so it has to be explicit.
- The gate check and enqueue are a single atomic step, so a call cannot pass the
  check just before the gate is raised and then run against a half torn-down SDK.
- Recovers if the background executor is shut down when the teardown or drain is
  submitted, which could happen when switchProject was called from inside a switch
  callback.

checkstyle: FileLength stays suppressed for IterableApi.java only, tracked in
SDK-677.
Comment thread iterableapi/src/main/java/com/iterable/iterableapi/IterableProjectSwitcher.java Outdated
switchProject took (apiKey, config) as two independently supplied halves of one
thing. IterableConfig carries the dataRegion and the IterableAuthHandler, so nothing
stopped one project's key being paired with another project's region or auth handler.
That mismatch produces an auth failure, or a request sent to the wrong region, that
looks unrelated to the project it came from, and the adoption guide had to warn about
it at length. Pairing the two makes it unrepresentable.

The public signature is now switchProject(context, project, callback). The
apiKey/config form stays as the package-private switcher entry point. Replacing
rather than overloading because this is unreleased: keeping both public would leave
the loose form available forever and reintroduce exactly what the pair removes.

IterableProject rejects a null or blank API key at construction, so an unusable
project is not constructible and a blank key can no longer reach a teardown. The
switcher keeps its own blank-key guard, because the internal entry point is also
reachable from initialize-time paths and from Kotlin callers whose platform types can
carry a null through. iOS uses a failable initializer for the same case: the
guarantee is identical, the mechanism follows each platform's convention.
The callback handed back a bare boolean, and the meaning of false had to be
explained everywhere it appeared: it does not mean the switch failed, it does not
mean it was rolled back, it is expected in normal operation for any app without
push, and the correct response to it is identical to the correct response to true.
A return value that needs that much prose to avoid being misread is carrying the
wrong shape.

onProjectSwitched now takes an IterableProjectSwitchResult of SWITCHED_CLEANLY or
SWITCHED_WITH_WARNINGS. Both names lead with "switched" so the thing that is true
in both cases, that the SDK is on the new project, is unmissable at the call site
rather than something the reader has to remember from the docs.

Internal plumbing still carries the teardown boolean set by the individual steps.
It converts once at the callback boundary through
IterableProjectSwitchResult.from, so the public contract is the named result while
the diff stays confined to the edge.

Deliberately two cases and no reason detail. Java enums can gain constants later
without breaking callers, so leaving room is cheap here, and inventing a warning
taxonomy before anyone has asked for one would be guesswork.
…n review

Three defects and one parity correction, all from PR review.

A switchProject for a second project while a switch was still running was
silently coalesced with the first. The SDK finished on the first project while
the second caller's callback reported a completed switch, so an app whose region
or brand picker was tapped twice sent every later event to the project the user
had already moved away from, with nothing in the API to detect it.
beginProjectSwitch now tells the two kinds of second request apart by where they
are headed: one asking for the project already in flight joins it, so a picker
tapped twice on the same destination still runs one teardown, and one asking for
a different project is parked in pendingSwitches and run when the switch in
flight lands.

The gate is handed to that parked request rather than lowered and raised again.
Lowering it in between leaves a window in which a brand new switchProject takes
the gate first and is then overtaken by the older parked request, so the SDK
settles on the project the app asked for second-to-last while every callback
still reports success. The switch callback is exactly where an app makes that
next call from, which puts it in that window. A parked request therefore runs
with resumingGate set, and owns releasing the gate it inherited if it bails out
before the teardown starts, since a gate left raised would queue every later SDK
call and never drain it. That also covers startPendingSwitch finding no
application context, which previously dropped the request's callbacks entirely
and left the app with no result at all.

The gate was also lowered before the queue was drained, so a setEmail made as
the switch landed could run ahead of the calls already waiting behind the gate
and then be overwritten by the older one drained behind it, which is the FIFO
order the gate exists to provide. The drain now decides the queue is empty under
initLock, the same lock enqueue takes, and lowers the gate only then.

Attribution resolved by a deep link redirect that was started on the previous
project was written into the new project's storage. The redirect is a network
round trip, so a switch can land in the middle of one, and the campaignId,
templateId and messageId it returns exist only in the project that was left, so
the new project's first attributed event would carry a campaignId it has never
heard of. RedirectTask now captures the API key that was live when the link was
clicked and hands it to a new setAttributionInfo overload. The key alone is not
enough, because between clearing the previous project's storage and the new
project coming up the previous project's key is still the live one, so a
projectScopedStorageCleared flag covers that window and both are checked under
projectStateLock together with the write. iOS already guarded this. The flag is
lowered after initialize rather than as soon as it publishes the new key, on
purpose: initialize runs processPendingAction, and a push tapped before the
switch carries no key of its own, so the flag is what stops it writing the
project it came from into the new project's attribution.

A switch called before initialize now reports SWITCHED_CLEANLY rather than
SWITCHED_WITH_WARNINGS, matching iOS. There is no previous project, so no
teardown step could have been noisy and there was no device to disable, and an
app using switchProject as its entry point would otherwise see a warning on
every cold start.
…K-673-switch-project

The base had moved five commits ahead, including a 3.10.1 release prep and a
merge of master, and the branch had been conflicted against it since 25 August.
GitHub cannot build the merge ref for a conflicted PR, so no pull_request
workflow had run on SDK-673 since then and the last two commits landed with no
CI at all.

One conflict, in IterableApi.getAuthManager(). SDK-566 renamed the constructor
argument to config.expiringAuthTokenRefreshPeriodMillis when it started
accepting fractional seconds; SDK-673 wrapped the lazy build in
synchronized (projectStateLock) so a background thread cannot build an auth
manager from a config IterableProjectSwitcher is halfway through replacing.
Resolved by keeping both.
@joaodordio
joaodordio marked this pull request as ready for review September 8, 2026 02:44
@joaodordio
joaodordio requested a review from a team as a code owner September 8, 2026 02:44
@Nullable IterableProjectSwitchCallback callback) {
synchronized (initLock) {
if (isSwitchingProject) {
if (apiKey.equals(inFlightSwitchApiKey)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Once a different destination is pending, a later request for the in-flight key cannot safely join the earlier switch.
For B in flight with C queued, a subsequent B request joins B's callbacks; those callbacks fire, then C runs and the SDK finishes on C even though B was requested last.
The global scan in parkPendingSwitchLocked similarly reorders non-adjacent duplicates such as C → D → C.

Suggest preserving request order and only coalesce an adjacent equivalent request.

// finished, which were posted to the same looper. Whatever the app does in those, in
// particular re-identifying the user, then runs against the project it was told it was on
// before this teardown starts.
new Handler(Looper.getMainLooper()).post(() ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The inherited gate is still raised while B's callback runs.
When C is pending after A → B and B's callback follows the public contract by calling setEmail or setUserId, that call is queued; C starts next, and C's completion drains the identity call against C. This sends B's intended identity to the wrong project.

Suggest ensuring callback work for B executes before C takes over, or delay B's callback until its calls cannot be replayed into C.

…r last

Mirrors the iOS fix for the same four ordering defects, found in review. The
first three are one mistake: the gate asked what project the SDK was on, when
the question is what project the app most recently asked to be on. Those two
differ for the whole length of a teardown, which includes the device disable
wait.

- A request for the project being left matched _apiKey, so it reported a clean
  switch, tore nothing down, and let the switch in flight land anyway. The app
  was then told it was on A while every event went to B.
- A repeat of the destination in flight joined it even with another destination
  queued behind it, so the chain settled past the project asked for last.
- parkPendingSwitchLocked scanned the whole queue, so C, D, C ran C then D and
  reported the last request when the first one landed.

One routing rule replaces all three, and pendingSwitches becomes an ArrayDeque
so the tail is reachable. The requested project is the tail of the queue, or the
switch in flight when the queue is empty, or the live key when nothing is
running. A request for it is absorbed by whatever is going to deliver it,
anything else goes on the tail, so adjacent repeats still share a single
teardown and a double tapped picker behaves as before.

The fourth was introduced by the previous handover fix. The gate both queued
calls and serialized switch requests, so holding it across a handover queued the
setEmail the contract tells apps to make from the callback and then replayed it
into the next project in the chain. lowerGate now always drops the call queueing
gate, isInitializing and isBackgroundInitialized, while isSwitchingProject marks
the chain and keeps new requests ordered behind it. A switch that inherits the
chain raises the call gate again through resumeProjectSwitch.

Each defect has a test in the new IterableSwitchProjectRoutingTest that fails
without the fix.
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