Skip to content

chore(mobile): ota backport for runtime 1.6.2 - #262

Open
GSTJ wants to merge 13 commits into
mainfrom
release/ota-1.6.2
Open

chore(mobile): ota backport for runtime 1.6.2#262
GSTJ wants to merge 13 commits into
mainfrom
release/ota-1.6.2

Conversation

@GSTJ

@GSTJ GSTJ commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Summary

This is a backport branch for the users still on runtime 1.6.2. It is based on 37fa5e2, the commit behind the last EAS update those users actually received, not on the v1.6.2 tag. CI kept publishing updates from main to runtime 1.6.2 until the universal links merge bumped the version to 1.7.1, so a 1.6.2 phone today is already running 37fa5e2, not the tag.

Do not merge this into main. It exists only to publish over the air updates to the 1.6.2 channel. Merging it would replay commits that are already on main.

Details

Ported on top of 37fa5e2, each as its own commit:

  • fix(mobile): stop the swipe screen replaying the last notification tap (backport of fix(mobile): stop the swipe screen replaying the last notification tap #231). The stored tap is keyed by the notification id and consumed once, so the mount path and the app wide listener cannot both report it. The pending dog profile store does not exist on this base, so its mock, harness and five tests are left out; the suite covers the replay cases only. The re-engagement push kind is on this base, so the kind carried into the reported open is kept.

  • fix(shared): put the Portuguese interface in sentence case (backport of fix(shared): put the Portuguese interface in sentence case #252). Only the pt-BR translation keys present on this base change: 29 values, no keys added or removed. The web.json half is dropped, because the footer and waitlist sections it rewrote are not on this base.

  • fix(mobile): make the empty deck reachable and its done state readable (backport of fix(mobile): stop the sign in banner from cutting the hero headline #248). The empty state column scrolls, the notify opt in stops being a half opacity disabled button once taken, and the preferences tap is counted. The SharePromptCard this column renders upstream belongs to the native share work, which is not on this base, so the card, its import, its test mock and the three tests that assert on it are left out. Everything else stands on its own.

  • feat: minimum app version per platform (backport of feat: minimum app version per platform #274). The minimum supported version becomes a per platform floor, and the client now sends its platform alongside its version so the server can answer with the right one. This is what makes retiring 1.6.2 possible: without it a 1.6.2 client sends no platform header and ignores the per platform floors, so the only way to move the floor would be to cut iOS and Android off together. The update wall also clears the stack before it navigates, sets its seen flag only once the navigation actually went through, and no longer accepts the back gesture, so one back swipe cannot drop someone back into the app the floor just locked. The pending dog profile import the original adds to the root layout is left out, since that store is not on this base. The packages/api half comes along because the mobile tRPC types infer from the router and the echo tests cover the floor logic; it is inert for this branch, since the API ships with the server and production already runs main.

  • feat(mobile): tag analytics with the running update (backport of feat(mobile): tag analytics with the running update #285). Every PostHog event now carries the update the device is actually running: ota_update_id, ota_is_embedded, runtime_version, ota_channel and ota_created_at, registered once as super properties so they ride along on exceptions too, not just on analytics.track calls. This is what makes "did the update reach anyone?" answerable for the people stuck on the 1.6.2 binary, whose $app_version says 1.6.2 forever. The metrics script half of the original is dropped: scripts/metrics/ does not exist on this base, and the readout runs from main.

  • fix(mobile): keep a signed in user out of the sign in loop (backport of fix(mobile): keep a signed in user out of the sign in loop #283). The launch query now retries under the shared transient policy with a four second ceiling per attempt and a ten second ceiling on the whole decision, and a device that already holds a token routes into the app instead of being dropped back on sign in when the network is slow. Both commits of the original come across. The packages/api half, which turns the already logged in throw into a coded CONFLICT, is left out: it ships with the server and production already runs main, and the mobile side only calls the existing echo route, so nothing here depends on it.

  • fix(mobile): re-upload profile photos when the grant went stale (backport of fix(api): let profile photos outlive a slow first form #284). Saving a profile whose upload grant expired now puts the photos back in the bucket and saves once more, exactly one extra attempt, and falls back to a toast in Portuguese and English telling the person to add the photos again. The two translation keys come along because they ship inside the bundle. The packages/api half, which lengthens the grant to an hour, is left out for the same reason as above, and it already reached production from main. The client side is the floor under that fix, not a replacement for it.

  • fix(mobile): look again when the keychain is not ready at launch (backport of fix(mobile): look again when the keychain is not ready at launch #286). The token read at launch now asks the keychain up to three times with a growing pause between attempts. iOS answers that read with "a required entitlement isn't present" when the app process is not attached yet, which is what a launch nobody started looks like: the system prewarming the app, or a notification waking it while the phone is still locked. A phone with no token is not affected, because an absent item comes back as an empty answer rather than a failure and never costs a retry, and a read that keeps failing still fails, so a real misconfiguration stays visible. Worst case this adds 450ms, and only to a launch that was already failing. Both halves of the original cherry pick clean onto this base, so nothing is left out. This one matters most here: every event in the audit came from the store build, so the 1.6.2 users are the only ones it reaches.

  • feat(mobile): say whether an upgrade was a real purchase (backport of the mobile half of fix(ci): show what upgrades bought and where subscription events go missing #290). A successful upgrade now carries source and is_sandbox next to the package it bought, so an App Store charge, a sandbox receipt and the premium grant the end to end flows hand themselves stop counting as the same thing in the readout. Only the store purchase produces a subscription event on the server, which is how successful upgrades could show up with nothing behind them. is_sandbox is null rather than false when the entitlement cannot be read, so a receipt nobody could open is not filed as real money. The server half of the original is left out: it ships with the server, and production already runs main.

  • feat(api): make the free daily like limit configurable (backport of feat(api): make the free daily like limit configurable #295). The free daily like allowance stops being a number compiled into the app. The launch query and the foreground check both read freeDailyLikeLimit off the echo route, and the refusal the server sends when someone runs out carries the allowance it actually enforced, so the limit sheet copy and the Like Limit Reached event report the number the server used rather than the number this binary was built with. A payload without the field leaves the shipped default of ten in place, which is what an older server answers with, so nothing regresses if the two sides disagree. All three commits cherry pick clean onto this base. The packages/api half comes along because the mobile tRPC types infer from the router; it is inert on this branch, since the server ships from main, where the same change already landed.

Deliberately not ported:

  • The paywall without a trial, the funnel events, the review ask after the first match and the empty deck actions. These already reached 1.6.2 users through CI updates from main before the version bump, so backporting them would be a no op at best.
  • The quiet empty deck, the sign in banner, the native share sheet and the story card. All of them need native code or components this base does not have.
  • The API only commits, which ship with the server and not with an update.

Measurement. The baseline from #188 is 800 re-engagement pushes sent this week with 0 opens recorded, because 1.6.2 does not track opens at all. The readout is the PostHog event Push Notification Opened filtered to app version 1.6.2.

Publish record, newest first:

  • Seventh publish: update group d2d03804-1c3d-4010-88ab-36e6fd594605 from commit 5f280c2 (server driven free daily like limit, feat(api): make the free daily like limit configurable #295) on channel main at runtime 1.6.2, iOS update 01a07b68-6aac-716b-b627-a6c11a576ff0. Preflight group 06eb7d5d-181c-4fb2-99e1-225d325b7ffd, iOS update 01a07b65-3d7e-7469-bccb-508f706ee4fa, was verified first on a release simulator build pinned to the ota-preflight channel and built before the preflight group was published. The binary carried expo-channel-name: ota-preflight in its update request headers at runtime 1.6.2. On the first launch the device logged didFinishBackgroundUpdateWithStatus=NewUpdateLoaded against that update id, and on the second it came back checkCompleteUnavailable with isUpToDate=1 and no update left to fetch. Its updates database recorded two successful launches and zero failed ones against that update id, and zero launches against the embedded bundle, so the published bundle is what ran both times. No crash was logged and the app rendered its screen past the splash on both starts. Rollback of this one alone is eas update:rollback d2d03804-1c3d-4010-88ab-36e6fd594605, which republishes fd7ca41a.

  • Sixth publish: update group fd7ca41a-bef1-4d2d-8a7c-25433753643e from commit 29696e0 (upgrade event properties, mobile half of fix(ci): show what upgrades bought and where subscription events go missing #290 by way of feat(mobile): say whether an upgrade was a real purchase #291) on channel main at runtime 1.6.2, iOS update 01a076f5-ed18-71d3-a992-31fc2e4df878. Preflight group e4fba8a7-4dc9-44e8-b8a7-eb990fbb8668, iOS update 01a076f2-8bce-76d5-b2e7-db21ceaf2ca9, was verified first on a release simulator build pinned to the ota-preflight channel and built before the preflight group was published. The binary carried expo-channel-name: ota-preflight in its update request headers, and the row the device stored for the published update id carries the same header back. That row came out ready with two successful launches and zero failed ones across two cold starts, against one launch for the embedded bundle, so the published bundle is what ran. No crash report was written, and the app rendered its screen past sign in both times. Rollback of this one alone is eas update:rollback fd7ca41a-bef1-4d2d-8a7c-25433753643e, which republishes c240833f.

  • Fifth publish: update group c240833f-e043-4762-987d-6c16623b188a from commit ac36c13 (keychain read retry at launch, fix(mobile): look again when the keychain is not ready at launch #286) on channel main at runtime 1.6.2, iOS update 01a071b6-cbd0-7f8c-b371-73a5c17bcf16. Preflight group e1ba57f2-d6b5-4be3-946e-5e0a4fd4c3fe, iOS update 01a071b2-b2ca-74e9-8d8e-2dd5ab9a1af4, was verified first on a release simulator build pinned to the ota-preflight channel and built before the preflight group was published. The device logged didStartLoadingUpdate against that update id, pulled all 52 assets with none failed, finished didFinishBackgroundUpdateWithStatus=NewUpdateLoaded, and came back checkCompleteUnavailable with isUpToDate=1 on the next launch. Its updates database recorded two successful launches and zero failed ones against that update id, and none at all against the embedded one, so the published bundle is what ran. The app rendered the sign in screen both times, which is the launch routing path this change touches. Rollback of this one alone is eas update:rollback c240833f-e043-4762-987d-6c16623b188a, which republishes ba2798f2.

  • Fourth publish: update group ba2798f2-6c48-49d3-a1e7-245f0197b5dd from commit 52c913a (sign in loop and stale upload grant, fix(mobile): keep a signed in user out of the sign in loop #283 and fix(api): let profile photos outlive a slow first form #284) on channel main at runtime 1.6.2. Preflight group 408792c9-e140-4408-9c18-d548b1ee52aa was verified on the same release simulator build first: it downloaded, logged NewUpdateLoaded, came back isUpToDate on the next launch, recorded two successful launches and zero failed ones, and the app rendered the sign in screen past the splash, which is the launch routing path this change touches. Rollback of this one alone is eas update:rollback ba2798f2-6c48-49d3-a1e7-245f0197b5dd, which republishes 19f2351e.

  • Third publish: update group 19f2351e-5cb2-4a06-9027-05cd054c68c5 from commit 5b74b2e (analytics tagged with the running update, feat(mobile): tag analytics with the running update #285) on channel main at runtime 1.6.2. Preflight group 48cb7ff0-88f3-4abf-9d88-d3cd7624c036 was verified on a release simulator build pinned to the ota-preflight channel first: the device downloaded it, logged NewUpdateLoaded and then isUpToDate on the next launch, recorded two successful launches and zero failed ones, and PostHog came up registering ota_update_id equal to the update the launcher actually loaded. Rollback of this one alone is eas update:rollback 19f2351e-5cb2-4a06-9027-05cd054c68c5, which republishes bc9927dc.

  • Second publish: update group bc9927dc-b7ce-4e0a-83d5-ac3f48a40c09 from commit b356e90 (minimum app version gate per platform, feat: minimum app version per platform #274) at 01:03 UTC on 2026-09-05, preflight group f86b0b0c-f6e4-4708-bbf1-0655ffcac1ba verified on device. Rollback of this one alone is eas update:rollback bc9927dc-b7ce-4e0a-83d5-ac3f48a40c09, which republishes 85e75f28.

Rollback chain, newest to oldest: d2d03804 to fd7ca41a to c240833f to ba2798f2 to 19f2351e to bc9927dc to 85e75f28 to 0eebf8e3.

Lesson recorded: build the preflight binary first, publish the preflight group second, or the binary refuses the older update.

Testing steps

  1. Run the type check, the linter, the formatter check and the mobile test suite at the repo root after each port. All pass.
  2. npx oxfmt --check over the 22 changed TypeScript files. All correctly formatted.
  3. Confirm apps/mobile/app.config.ts still says version 1.6.2 with the appVersion runtime policy, and that apps/mobile/package.json and pnpm-lock.yaml are byte identical to 37fa5e2. No dependency moves, so the runtime stays compatible with the binary already on people's phones.
  4. Grep the whole range for expo-sharing, react-native-view-shot, expo-clipboard, associatedDomains, intentFilters, DogShareOptions and usePendingDogProfile. Zero hits, so nothing here needs a native rebuild.
  5. Check the version floor both ways: a client below its platform floor is sent to the update wall, and one at or above it is not. The echo tests cover the per platform answer, including a client that sends no platform at all.
  6. Publish to the ota-preflight channel and open a release build of 1.6.2 pointed at that channel. The update downloads, the app relaunches on it, and the screenshots below come from that build.
  7. On an account without a subscription, use up the free likes for the day. The sheet that appears names the same number of likes the server allows, and it still names ten when the server does not send a number at all.

Screenshots

Paywall, yearly preselected with the saving badge:

Paywall with the yearly plan preselected

Full paywall, no free trial copy anywhere in the column:

Paywall scrolled to the bottom with no trial copy

Portuguese in sentence case, sign in:

Sign in screen in Portuguese sentence case

Portuguese in sentence case, create profile:

Create profile screen in Portuguese sentence case

@GSTJ GSTJ mentioned this pull request Sep 4, 2026
GSTJ added 3 commits September 4, 2026 19:11
The stored tap is now keyed by the notification id and consumed once. Both
listeners claim the tap before handling it, so the mount path and the app wide
listener cannot both report the same one.

Hand ported: this base has no pending dog profile store, so the
usePendingDogProfile mock, harness and its five tests are left out and the
suite covers the replay cases only. The re-engagement push kind is on this
base, so the kind passed through to the reported open is kept.

Backport of #231.
Title Case is an English convention. Portuguese capitalises the first word and
proper nouns only, so the interface read as a translation rather than as
Portuguese.

Hand ported: only the pt-BR translation keys that exist on this base are
changed. The web.json part is dropped, since the sections it rewrote (footer
and waitlist) are not on this base.

Backport of #252.
The empty state column runs past the fold on a short phone, and the last thing
in it is the preferences button the copy points at, so it scrolls now. The
notify opt-in stops being a disabled button once it is taken: a disabled button
paints its label at half opacity, 1.7:1 on the light background, with nothing
saying why it would not respond. A check and a full contrast label read as an
answer instead. The preferences tap is counted, so the empty deck funnel can
see the action the copy actually asks for.

Hand ported: the SharePromptCard the original renders inside this column
belongs to the native share work, which is not on this base, so the card, its
import, its test mock and the three tests that assert on it are left out. The
reachability, done state, analytics and copy changes stand on their own.

Backport of #248.
@GSTJ
GSTJ force-pushed the release/ota-1.6.2 branch from e4bb526 to 7736f26 Compare September 4, 2026 22:15
GSTJ added 10 commits September 4, 2026 21:00
The floor is per platform now, and the client sends the platform with the
version so the server can answer with the right one. Without this a 1.6.2
client sends no platform header and ignores the per platform floors, so there
is no way to retire the old runtime without cutting off both platforms at once.

The update wall also clears the stack before it navigates, sets its flag only
once the navigation went through, and no longer takes the back gesture, so a
single back swipe cannot put someone back into the app the floor just locked.

Hand ported: this base has no pending dog profile store, so that import is left
out of the root layout. The packages/api half is kept because the mobile tRPC
types infer from the router and the echo tests cover the floor logic; it is
inert here, since the API ships with the server and production already runs
main.

Backport of #274.
feat(mobile): say whether an upgrade was a real purchase
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