Skip to content

[MOBILE-3815] Review follow-up: upgrade data loss, Kotlin downgrade, live activity models, and release automation - #142

Merged
oristanovic merged 7 commits into
MOBILE-3815from
MOBILE-3815-followup-fixes
Aug 21, 2026
Merged

[MOBILE-3815] Review follow-up: upgrade data loss, Kotlin downgrade, live activity models, and release automation#142
oristanovic merged 7 commits into
MOBILE-3815from
MOBILE-3815-followup-fixes

Conversation

@oristanovic

@oristanovic oristanovic commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #137, stacked on #141. Addresses the defects still open after that merge, plus the release pipeline.

Base is MOBILE-3815 rather than main so the diff is only these five commits.

Blocking fixes

Fix Why it matters
AirshipUpdater migrates drawables from both legacy roots 9.x's own updater moved notification icons into urbanairship-resources.androidlib/res, which this release adds to obsoleteDirectories. Upgrading deleted customer icons. Also merges into an existing destination instead of throwing, and refuses to delete a directory still holding drawables.
Kotlin plugin version is a floor, not a pin AirshipAndroidGradlePostProcess rewrote the version unconditionally, downgrading customers already on newer Kotlin and breaking their builds. Unparseable versions ($kotlin_version, unsubstituted placeholders) are now left alone.
trackScreen(screenName: String?) AirshipAnalytics.TrackScreen documents passing null to stop tracking; iOS handled it, Android threw on Kotlin's null check. Closes the inline thread.
Android event pump no longer drops early events It consumed pending events before Unity registered its listener, losing the first channel-created / push-token event of a cold start. Now left pending and replayed from setListener. Also one processPending pass instead of one per EventType.
Live Activity / Live Update inbound models LiveActivityInfo.attributes, LiveActivityContent.state and LiveUpdate.content are arbitrary JSON objects on the proxy side; JsonUtility silently left all three empty. Flattened natively into parallel key/value arrays and paired back in C#, matching the existing PushMessage.Extras approach.

Release pipeline

The tag-triggered release has never worked. main had no packaging step at all (9.x assets were attached by hand), and the step added in #137 could not resolve a Unity editor on a hosted runner — getUnityExePath() reads $UNITY_EXE or a macOS Unity Hub path, so ubuntu-latest threw Unable to find Unity executable. On top of that both matrix legs uploaded an identical package under one artifact name (409 on upload-artifact@v4) and every path still said urbanairship-* after the gradle output was renamed to airship-*.

Now: the package is built once in its own job inside the game-ci editor image (the same image the Android player leg already pulls, verified published on Docker Hub), with serial activation and license return mirroring game-ci's own scripts. if-no-files-found: error and fail_on_unmatched_files: true mean a future rename fails loudly rather than publishing an empty release.

The file carries a header runbook for the failure modes that can only surface on a real run, and a safe way to exercise it: push a prerelease tag such as 10.0.0-rc1plugin and package both run, then Verify Version fails release on the version mismatch before anything ships.

Non-blocking

  • EnumExtensions moved out of the global namespace into AirshipSDK. As a global static with a this Enum extension it broke the compile of any customer with their own EnumExtensions.
  • InboxMessage.listIconUrl exposed. Both natives already put it on the wire; C# dropped it.
  • InternalInboxMessage and InboxMessage(InternalInboxMessage) made internal — it is the wire shape, and the name already said so.
  • Inbox extras now go through AirshipUtils.PairFlattenedObject, which also removes a latent IndexOutOfRange when the values array came back shorter than the keys array.
  • AirshipPostBuild skips its shell-script phase when already present; Append builds were stacking a duplicate copy step every build.
  • OnAuthorizedSettingsChanged documented as iOS-only in the migration guide.
  • Unused System.Linq import and an unreachable nullable-enum branch removed.
  • 12 new tests covering the new wire contracts.

Review threads

Fixed: trackScreen, Live Activity state, event race. Documented: OnAuthorizedSettingsChanged — no Android equivalent exists, so it cannot fire there.

Two threads are false positives and need no code change. Kotlin .optMap().opt("values") and iOS editChannelSubscriptionLists both assume the C# side sends a bare JSON array. It doesn't: TagEditor/TagGroupEditor/SubscriptionListEditor/ScopedSubscriptionListEditor all use JsonArray<T>.ToJson(), which is JsonUtility.ToJson(this) and emits {"values":[…]}. JsonArray.cs is byte-identical to main apart from the namespace, and main's shipped Java did the same opt("values") unwrap. The wrapper is the established contract. I did normalize editChannelSubscriptionLists onto the tolerant helper its siblings use, since it was the only case where the payload shape was load-bearing.

Still open, needs a decision rather than a patch: JNI concurrency — add a lock defensively, or close it as investigated.

Beyond the agreed scope

One extra fix: outbound Live Update requests sent dismissTimestamp, but LiveUpdateRequest.fromJson reads dismissalTimestamp, so the value never arrived on Android. Found while verifying the models against the proxy source; a one-word change in records I was already rewriting. The inbound model correctly keeps dismissTimestamp — that asymmetry is the proxy's.

Deliberately skipped: deduplicating the Kotlin version between build.gradle and airship.properties. Gradle's plugins {} block can't read an external property, so every workaround restructures settings.gradle — low value, real risk of breaking the Android build.

Build coverage

CI compiles the C#, and the first run on this branch caught a real error I introduced: splitting the live activity content model, I renamed LiveActivityContent instead of adding alongside it, leaving the three outbound request records pointing at a deleted type (CS0246 in both matrix legs). Restored in ff0cdae.

What CI does and does not cover, so the remaining gaps are explicit:

Area Covered by CI?
C# (AirshipSDK.dll) Compiled in both matrix legs
Unit test assembly Compiled, never run. There is no test-runner step, so the 58 tests are compile-checked only
Kotlin (unity-plugin) Not compiled. No AAR is checked in and the unity-plugin gradle module is never built by the Build step
Swift (Assets/Plugins/iOS) Not compiled. The iOS leg generates the Xcode project; nothing builds it on a Linux runner
Release workflow Never run. It only triggers on tags

So the Kotlin event-pump and live-update changes and the Swift flattening changes are still unexercised by anything. Before merging, worth an Android and an iOS build from a dev machine, running the test suite in the editor, and a 10.0.0-rc1 tag to exercise the release workflow without publishing.

🤖 Generated with Claude Code

Ognjen Ristanovic and others added 5 commits August 21, 2026 13:19
…ive activity models

Six blocking defects from the PR #137 review follow-up.

Editor:
- AirshipUpdater migrated drawables only from urbanairship-plugin-lib/res, but the
  9.x updater had already moved them into urbanairship-resources.androidlib/res --
  which this release adds to obsoleteDirectories. Upgrading deleted the customer's
  notification icons. Migrate from both legacy roots, merge into an existing
  destination instead of failing, and refuse to delete a directory that still holds
  drawables.
- AirshipAndroidGradlePostProcess rewrote the Kotlin plugin version unconditionally,
  downgrading customers already on a newer Kotlin and breaking their build. Treat the
  requirement as a floor: only ever raise, leave unparseable versions (Gradle
  variables, unsubstituted placeholders) alone.

Release:
- Both matrix legs uploaded an identical package under one artifact name (409 on
  upload-artifact@v4) and every path still said urbanairship-* after the gradle
  output was renamed to airship-*. Build the platform-independent package once in its
  own job, fix the globs, and set if-no-files-found/fail_on_unmatched_files so a
  future rename fails loudly instead of publishing an empty release.

Native bridges:
- trackScreen's Kotlin parameter was non-null, so the documented TrackScreen(null)
  call to stop tracking threw on Android while iOS handled it.
- The Android event pump consumed pending events before Unity registered its
  listener, dropping the first channel-created/push-token event of a cold start.
  Leave them pending and replay from setListener. Also collapses the per-EventType
  processPending walk into one pass.

Live Activity / Live Update models:
- LiveActivityInfo.attributes, LiveActivityContent.state and LiveUpdate.content are
  arbitrary JSON objects on the proxy side; JsonUtility silently left all three
  empty. Flatten them into parallel key/value arrays natively and pair them back in
  C#, matching the existing PushMessage.Extras approach.
- Outbound Live Update requests sent dismissTimestamp, but the proxy reads
  dismissalTimestamp, so the value never arrived. Found while verifying the models
  against the proxy source; beyond the listed scope but a one-word fix in the same
  records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…review items

The non-blocking half of the PR #137 review follow-up.

- EnumExtensions was a global-namespace static class with a `this Enum` extension. A
  customer with their own EnumExtensions got a compile error on upgrade. Moved into
  AirshipSDK; every consumer already lives there, so nothing else changed.
- Exposed InboxMessage.listIconUrl. Both natives already put it on the wire (Kotlin
  sets it directly, the iOS proxy maps listIconURL -> "listIconUrl") and C# dropped it.
- Made InternalInboxMessage and the InboxMessage(InternalInboxMessage) constructor
  internal. It is the wire shape, not something callers should build, and the name
  already said so. Visible to the test assembly via InternalsVisibleTo.
- Inbox extras now go through AirshipUtils.PairFlattenedObject rather than a
  hand-rolled loop, which also drops a latent IndexOutOfRange when the values array
  came back shorter than the keys array.
- AirshipPostBuild added its shell script phase unconditionally, so an Append build
  stacked another copy step on every build. Skip when the phase already exists.
- editChannelSubscriptionLists was the only iOS editor case using an inline strict
  "values" guard instead of the tolerant helper the siblings use. Not a bug today --
  JsonArray.ToJson does emit the wrapper -- but it made the payload shape load-bearing
  in one spot and not the others.
- Documented OnAuthorizedSettingsChanged as iOS only. Authorized notification settings
  have no Android equivalent, so the event cannot fire there.
- Dropped the unused System.Linq import in AttributeEditor and the unreachable
  nullable-enum branch in AirshipUtils (boxing a Nullable<T> yields T, so it never ran).
- Added tests for the new wire contracts: listIconUrl, live update content, live
  activity attributes and content state, and PairFlattenedObject's edge cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The packaging step could not run on a hosted runner: build.gradle drives Unity
directly for -importPackage/-exportPackage, and getUnityExePath() resolves the editor
from $UNITY_EXE or a macOS Unity Hub path, so on ubuntu-latest it threw "Unable to
find Unity executable". The tag-triggered release has therefore never produced the
package; 9.x assets were attached by hand.

Run the packaging job inside the game-ci editor image, which is the same image the
existing player-build job already pulls, so no new infrastructure is involved:

- A small `editor` job reads m_EditorVersion from ProjectSettings/ProjectVersion.txt --
  the file unity-builder itself reads -- and derives the image tag from it, so bumping
  Unity cannot leave CI on a stale editor. Tag format and the rolling version default
  come from unity-builder's ImageTag.
- Serial activation and license return mirror game-ci's own activate.sh: both need a
  -projectPath, and the seat is returned with if: always() because serial seats are
  limited. A failed return is not swallowed -- a leaked seat is worse than a red job.
- The android image exports JAVA_HOME/ANDROID_HOME/ANDROID_SDK_ROOT only through the
  unity-editor wrapper's env fragments, which gradle does not go through, so the step
  sources them before invoking gradle.
- UNITY_EXE points at /usr/bin/unity-editor rather than /opt/unity/Editor/Unity so the
  editor calls run under xvfb, as they do everywhere else in game-ci.

Not verified: this cannot be exercised offline. Whether the resolved image tag is
published for this editor version, and whether headless serial activation succeeds with
this account's license type, will only be known from a real tag run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Simplifies the previous commit and writes down what is still unverified, so a red tag
run is debuggable from the file rather than from memory.

- Dropped the `editor` job and pinned package.container.image to a literal tag. The
  job existed only to avoid hardcoding the tag, and it put the riskiest assumption in
  the file on the critical path: if `container.image` does not accept a `needs`
  expression the workflow fails to parse, which is worse than any staleness it
  prevented.
- Kept the protection it was there for, more cheaply and more correctly. The game-ci
  image records its editor version at $UNITY_PATH/version, so a first step compares the
  real container against ProjectSettings/ProjectVersion.txt. That catches drift in
  either direction with no duplicated version literal.
- Restored publish-docs' `needs: [release, plugin]`. Narrowing it to [release] was
  behaviour-preserving but unrequested churn.
- Added a header runbook covering the failure modes that can only show up on a real
  run: unpublished image tag, editor drift, Personal-vs-Pro license activation, a
  leaked serial seat, the JDK/Android SDK env fragments, and the two guards that stop
  an empty release. Plus the safe way to exercise it -- a prerelease tag runs `plugin`
  and `package` and then fails Verify Version before publishing anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Docker Hub was reachable after all: unityci/editor:ubuntu-6000.2.8f1-android-3 exists
(pushed 2026-03-19, linux/amd64, active), as does the -ios-3 sibling the player leg
uses. Replaced the "may not be published" caveat with the curl that lists available
tags, and recorded that the trailing -3 is game-ci's rolling image revision -- an alias
over their 3.2.x builds -- so the editor segment is the only part needing maintenance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oristanovic
oristanovic requested a review from a team as a code owner August 21, 2026 11:45
Ognjen Ristanovic and others added 2 commits August 21, 2026 13:54
Fixes the CI compile failure in both matrix legs: CS0246 at
AirshipLiveActivityManager.cs 202/211/219.

Splitting the live activity content model into inbound and outbound halves, I renamed
the type to LiveActivityContentInfo by replacing the original rather than adding
alongside it, leaving LiveActivityStartRequest/UpdateRequest/EndRequest referencing a
type that no longer existed. The doc comment on the new type even cross-referenced the
one I had deleted.

LiveActivityContent is back as the outbound type, keeping Dictionary<string, object>
state: outbound payloads go through AirshipUtils.Serialize, which handles dictionaries,
so only the inbound path needs the parallel-array workaround.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On a tag push, `package` ran alongside both `plugin` matrix legs, so the release needed
three simultaneous Unity license activations where CI has only ever demonstrated two.
A serial has a fixed seat count and each Unity job holds one for its duration, so the
extra job could fail activation with an error that reads as a broken workflow rather
than a capacity limit.

needs: [plugin] puts peak concurrency back at two, matching what CI already proves
works. It also stops the run spending packaging time when the players do not build.
Costs wall-clock: packaging now waits for both player legs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oristanovic
oristanovic merged commit 83dc580 into MOBILE-3815 Aug 21, 2026
2 checks passed
@oristanovic
oristanovic deleted the MOBILE-3815-followup-fixes branch August 21, 2026 15:49
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