Author SHA1 Message Date
jjbrunton 439f506fff feat(dares): seed 150-item catalogue 2026-09-05 22:30:39 +01:00
jjbrunton d998e32047 merge: require upfront OTA assessment 2026-09-05 22:15:42 +01:00
jjbrunton 7edd957547 docs(agents): require upfront OTA assessment 2026-09-05 22:15:29 +01:00
jjbrunton fdaa8c47bb merge: add full question catalogue draft 2026-09-05 21:41:20 +01:00
jjbrunton 04328fb3a8 fix(catalogue): integrate reviewed question sources 2026-09-05 21:36:20 +01:00
jjbrunton f373e3beb4 docs(catalogue): add full live catalogue draft 2026-09-05 21:03:46 +01:00
jjbrunton 583d38eb8c merge(staging): preserve main admin fixes 2026-09-05 20:51:32 +01:00
jjbrunton d604cd7a11 fix(catalogue): avoid literal em dash in validator 2026-09-05 20:28:26 +01:00
jjbrunton ddf1e33a4e merge: add play-safe spicy question draft 2026-09-05 20:27:55 +01:00
jjbrunton da1c310738 docs(catalogue): trim draft source whitespace 2026-09-05 20:27:31 +01:00
jjbrunton 5c8c9cc9a5 docs(catalogue): finalise spicy question draft 2026-09-05 20:24:27 +01:00
jjbrunton c297f03b56 docs(catalogue): add audio concept source 2026-09-05 20:16:34 +01:00
jjbrunton c79d993cf5 docs(catalogue): preserve text answer concepts 2026-09-05 20:14:08 +01:00
jjbrunton b5e6b70d54 docs(catalogue): repair spicy swipe source 2026-09-05 20:07:14 +01:00
jjbrunton 2a2cbe9e85 docs(catalogue): add concept set b source 2026-09-05 19:59:02 +01:00
jjbrunton eb54aa4daa docs(questions): add concept set a swipe drafts 2026-09-05 19:57:48 +01:00
jjbrunton e509ac732f docs(catalogue): replace swipe draft concepts 2026-09-05 19:45:51 +01:00
jjbrunton a72fe9c06d docs(catalogue): expand draft pack themes 2026-09-05 19:36:52 +01:00
jjbrunton 450c527d1e docs(question-types): document interaction contract 2026-09-05 19:33:17 +01:00
jjbrunton 6c7219c741 docs(catalogue): refine spicy draft copy 2026-09-05 19:31:57 +01:00
jjbrunton b02a105e10 docs(catalogue): add play-safe spicy draft 2026-09-05 19:29:05 +01:00
jjbrunton f4687f95fe Merge pull request #29 from jjbrunton/staging
fix(admin): prevent blank dashboard after login
2026-09-05 17:12:45 +01:00
jjbrunton 53a48ab6d5 fix(admin): use feature interest composite key 2026-09-05 17:02:07 +01:00
jjbrunton a6233d0175 fix(admin): render standalone feature interests 2026-09-05 16:57:57 +01:00
jjbrunton c776859183 Merge pull request #28 from jjbrunton/staging
Promote staging to main: Apple review onboarding fixes and v1.0.8 build 49
2026-09-01 16:16:37 +01:00
jjbrunton cc12f5f0ff Merge remote-tracking branch 'origin/main' into sync/staging-backmerge 2026-09-01 16:10:11 +01:00
jjbrunton 3659cc8c2f chore(ios): record v1.0.8 build 49 2026-09-01 14:08:05 +01:00
jjbrunton 4b227ad62c chore(mobile): prepare iOS build 48 2026-09-01 13:47:47 +01:00
jjbrunton 7c8d91c89c fix(mobile): address Apple review onboarding flows 2026-09-01 09:42:48 +01:00
jjbrunton fe1248c59d Merge pull request #25 from jjbrunton/staging
Staging
2026-08-31 20:56:43 +01:00
jjbrunton 6883befff3 fix(mobile): persist matches tutorial dismissal 2026-08-31 19:39:33 +01:00
jjbrunton a06f1b69e8 Merge pull request #27 from jjbrunton/hotfix/admin-cors-origin
fix(api): allow the admin console origin through CORS
2026-08-31 18:22:23 +01:00
jjbruntonandClaude Opus 5 3a019e62d3 fix(api): allow the admin console origin through CORS
The production allowlist held only https://sauci.app, so every
/v1/admin/* preflight from https://manage.sauci.app returned 204 with no
Access-Control-Allow-Origin and the browser blocked the request. Login
then reported "Access denied. You are not an administrator." because the
admin app treats any /v1/admin/me failure as a permissions failure.

Add the admin console origin and record in the README that new admin
hosts belong in the Compose contract rather than a Dokploy override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:26 +01:00
jjbruntonandClaude Opus 5 636811e66a fix(api): allow the admin console origin through CORS
The production allowlist held only https://sauci.app, so every
/v1/admin/* preflight from https://manage.sauci.app returned 204 with no
Access-Control-Allow-Origin and the browser blocked the request. Login
then reported "Access denied. You are not an administrator." because the
admin app treats any /v1/admin/me failure as a permissions failure.

Add the admin console origin and record in the README that new admin
hosts belong in the Compose contract rather than a Dokploy override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:10 +01:00
jjbrunton 84712a950e Merge pull request #26 from jjbrunton/hotfix/admin-docker-context
fix(admin): restore admin docker build context, allow hotfix branches into main
2026-08-31 18:08:31 +01:00
jjbruntonandClaude Opus 5 d14869d745 ci(branch-policy): allow hotfix and release branches into main
The policy accepted only `staging` as a head for main, so any change
production needed before the next promotion had nowhere to go: the PR was
unmergeable by CI and branch protection blocks direct pushes.

Accept `hotfix/*` and `release/*` heads as well. The naming requirement
keeps a direct-to-main change deliberate and visible, and the master
freeze plus the "features target staging" rule are unchanged. Document
the escape hatch and the need to land the same commit on staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:48 +01:00
jjbruntonandClaude Opus 5 f1573f4906 fix(admin): keep admin app in the docker build context
The root .dockerignore excluded apps/admin, so the monorepo build context
dropped that workspace entirely: npm ci skipped it and
`npm run build -w @sauci/admin` failed with "No workspaces found".
The ignore file was authored for the mcp image and later extended with
re-includes for web; admin never got the same treatment.

Re-include apps/admin, and re-exclude node_modules/dist last so the
re-includes cannot pull host build artefacts into any image context.

Verified with `docker build -f apps/admin/Dockerfile .`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:48 +01:00
jjbruntonandClaude Opus 5 d825e3d80c ci(branch-policy): allow hotfix and release branches into main
The policy accepted only `staging` as a head for main, so any change
production needed before the next promotion had nowhere to go: the PR was
unmergeable by CI and branch protection blocks direct pushes.

Accept `hotfix/*` and `release/*` heads as well. The naming requirement
keeps a direct-to-main change deliberate and visible, and the master
freeze plus the "features target staging" rule are unchanged. Document
the escape hatch and the need to land the same commit on staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:34 +01:00
jjbruntonandClaude Opus 5 13094267a6 fix(admin): keep admin app in the docker build context
The root .dockerignore excluded apps/admin, so the monorepo build context
dropped that workspace entirely: npm ci skipped it and
`npm run build -w @sauci/admin` failed with "No workspaces found".
The ignore file was authored for the mcp image and later extended with
re-includes for web; admin never got the same treatment.

Re-include apps/admin, and re-exclude node_modules/dist last so the
re-includes cannot pull host build artefacts into any image context.

Verified with `docker build -f apps/admin/Dockerfile .`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:56:41 +01:00
jjbruntonandClaude Fable 5 ecc31a13a2 feat(release): enforce manifest and Info.plist release invariants
Extend release-preflight to reject an Android manifest missing the
READ_MEDIA_IMAGES/READ_MEDIA_VIDEO tools:node="remove" attributes and
an iOS Info.plist that regained dev-launcher-only keys, both
regressions introduced silently by expo prebuild during the SDK 54 to
56 upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 16:03:04 +01:00
jjbruntonandClaude Fable 5 7a76936829 docs(agents): promote v1.0.8 release learnings
Record three confirmed v1.0.8 failure modes (expo prebuild native
regressions, npm 10/11 lockfile divergence, local EAS build
preconditions) and promote each into docs/releasing.md, a new
docs/development/dependencies.md, and the release-preflight script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 16:03:02 +01:00
jjbrunton 83df3b45f8 Merge pull request #23 from jjbrunton/staging
Release v1.0.8: Expo SDK 56 and EAS Observe
2026-08-31 15:47:29 +01:00
jjbruntonandClaude Fable 5 9609a9084b fix(deps): pin @react-native/metro-config to end npm 10/11 lock divergence
react-native-worklets depends on @react-native/metro-config with a
wildcard range; npm 11 (local) resolves and hoists 0.87.x where npm 10
(CI runners and Docker images) requires 0.85.3 at the same position, so
every lockfile regeneration broke one side. Pin 0.85.3 via the root
overrides and regenerate: npm ci now passes on npm 10.8.2 linux/amd64
(SWC, rollup, and lightningcss platform binaries all install) and on
local npm 11, with verify:fast green on both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:43:49 +01:00
jjbruntonandClaude Fable 5 90cde4aca4 fix(web): pin Next SWC binaries and regenerate lockfile with CI's npm
Since the SDK 56 dependency churn, arborist nests next under apps/web and
npm 10 skipped its platform-optional SWC binaries on the x64 CI runner,
breaking next build. Pin the linux and darwin binaries as direct
optionalDependencies of @sauci/web (bump alongside next) and regenerate
the lockfile from scratch with npm 10.8.2 to match CI. Verified in a
linux/amd64 node:20.19.4 container: npm ci installs the binary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:29:01 +01:00
jjbruntonandClaude Fable 5 ecff9e2ec8 fix(deps): regenerate lockfile with npm 10 for CI compatibility
Today's dependency changes left orphaned @react-native 0.87.x entries
that npm 10's ci validation rejects; npm 11 tolerated them locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:02:09 +01:00
jjbrunton ffc655f576 Merge remote-tracking branch 'origin/main' into staging 2026-08-31 14:59:44 +01:00
jjbruntonandClaude Fable 5 d8bedead0c chore(ios): sync widget Xcode build number to 47
EAS auto-increment updates the Info.plists but not the widget target's
CURRENT_PROJECT_VERSION; the shipped widget binary already carries 47
from its Info.plist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:59:02 +01:00
jjbruntonandClaude Fable 5 6c3a181613 chore(android): record v1.0.8 build 51
AAB sha256 4893ee6f64f90bb1b2e1f13c6b088cbe75103337fbc101751da7076224778c29
verified: com.sauci.app 1.0.8 (51), production upload key, four ABIs,
Worklets/Reanimated/Skia libraries, no broad media read permissions,
production OTA endpoint, channel, and code-signing certificate. Supersedes
the build 50 artifact, which predated the skia 2.6.9 bump and the
expo-av migration and was quarantined unshipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:58:10 +01:00
jjbruntonandClaude Fable 5 d8ff39a2f3 chore(ios): record v1.0.8 build 47
IPA sha256 760517ccf474d496c940c3f84825f4852306d840d913090610a77dc24450ee36
verified: com.sauci.app and com.sauci.app.LiveDrawWidget both 1.0.8 (47),
team BXVPBATDZY, associated domains, shared app group, production OTA
runtime, endpoint, and code-signing certificate, no dev-launcher keys.
Also syncs the tracked Podfile.lock to the archive's resolution (expo-av
removed; ExpoVideo, ExpoAudio, ExpoSystemUI added; skia 2.6.9).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:46:44 +01:00
jjbruntonandClaude Fable 5 b8cb2addff fix(mobile): migrate expo-av to expo-video and expo-audio
expo-av has no SDK 56 compatible release, and its EXEventEmitter.h
interface was removed from expo-modules-core, so the iOS release
archive fails compiling Pods/EXAV. Move video playback to
expo-video's useVideoPlayer/VideoView, and audio playback and
recording to expo-audio's createAudioPlayer/AudioModule.AudioRecorder,
preserving existing controls, mute/loop/resize behavior, and the
recording audio session configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:35:51 +01:00
jjbruntonandClaude Fable 5 5e28117662 fix(ios): bump react-native-skia to 2.6.9 for the base64 header fix
2.6.2 fails to archive under the SDK 56 toolchain: base64.cpp includes
third_party/base64.h, which the podspec header glob no longer resolves.
2.6.9 includes the header relative to its own directory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:15:42 +01:00
jjbruntonandClaude Fable 5 196428f796 chore(android): record v1.0.8 build 50
AAB sha256 29c9bee3949e51b027c558637c04eefec96bf7f177151cb9571ca2a289ffee4e
verified: com.sauci.app 1.0.8 (50), production upload key, four ABIs,
Worklets/Reanimated/Skia libraries, no broad media read permissions,
production OTA endpoint, channel, and code-signing certificate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:07:58 +01:00
jjbruntonandClaude Fable 5 6bd04a6c06 fix(ios): keep dev-launcher keys out of the release Info.plist
The SDK 56 prebuild added NSBonjourServices, RCTMetroPort, and the Expo
Dev Launcher NSLocalNetworkUsageDescription to the tracked Info.plist.
v1.0.7 shipped without them and the production app has no local-network
feature to declare.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 13:43:56 +01:00
jjbruntonandClaude Fable 5 7aca39aa8f fix(android): restore blocked media permission removals in manifest
The SDK 56 prebuild dropped the tools:node=remove entries for
READ_MEDIA_IMAGES and READ_MEDIA_VIDEO, so the merged release manifest
regained permissions the Play media policy forbids; Sauci uses the system
photo picker. Restore the removals shipped in v1.0.7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 13:42:44 +01:00
jjbruntonandClaude Fable 5 79233f58aa fix(android): raise Gradle daemon memory for RN 0.85 toolchain
The prebuild-regenerated gradle.properties restored stock 2GB heap and
512MB Metaspace limits; KSP and CMake configuration under SDK 56 exhaust
Metaspace and fail the release build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 13:19:41 +01:00
jjbruntonandClaude Fable 5 c9e8835810 fix(mobile): scope Expo exports to native platforms
A bare expo export (used by the OTA preflight and by eoas publish) also
exported the unused web target, whose static render breaks under SDK 56.
XPREM serves only iOS and Android bundles and the website is apps/web, so
declare the Expo app native-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:12:18 +01:00
jjbruntonandClaude Fable 5 ff8f99f418 chore(release): prepare v1.0.8
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:07:13 +01:00
jjbruntonandClaude Fable 5 604eec3322 fix(ios): restore LiveDrawWidget identity after apple-targets 5 regeneration
The SDK 56 prebuild derived the widget bundle id from its folder name
(com.sauci.app.widget) and pointed CODE_SIGN_ENTITLEMENTS at the ignored
ios/.targets cache. Restore the shipped com.sauci.app.LiveDrawWidget
identity, the tracked entitlements file, and pin bundleIdentifier in the
target config so future prebuilds keep it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:07:13 +01:00
jjbrunton dbf548c7bd Merge remote-tracking branch 'origin/staging' into staging
# Conflicts:
#	docs/releasing.md
2026-08-31 11:54:50 +01:00
jjbrunton 4973f2c631 Merge remote-tracking branch 'origin/staging' into staging
# Conflicts:
#	.codex/skills/release-mobile/SKILL.md
#	apps/mobile/package.json
#	docs/releasing.md
2026-08-31 11:34:33 +01:00
jjbruntonandClaude Fable 5 2b05e50312 feat(release): add fastlane ship lanes and end-to-end release pipeline docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:32:32 +01:00
jjbruntonandClaude Opus 5 26312e5a58 docs(release): a release request carries through to rollout
The upload execution policy stopped an agent at upload: App Review
submission and rollout each needed their own explicit authorization, so a
request to ship a release ended with an artifact sitting in App Store
Connect and a question back to the user. That contradicted the harness
standard in documentation/standards/react-native.md, which has always
authorized the full pipeline, and it is the wrong default: every step from
here is recoverable. A submission can be cancelled while it waits for
review, a phased release paused, a staged rollout halted.

A release request now authorizes build, verification, upload, App Review
submission, and rollout for the platforms it names.

What still needs its own authorization is anything that is not part of
shipping the requested release: widening TestFlight distribution to new
groups, and re-uploading after an ambiguous upload result. The existing
automatic-TestFlight-distribution check before an iOS upload is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:31:08 +01:00
jjbrunton 66c25849e9 Merge remote-tracking branch 'origin/staging' into task/expo-56-observe
# Conflicts:
#	docs/releasing.md
2026-08-31 11:15:09 +01:00
jjbruntonandClaude Fable 5 5c4a4608b1 fix(mobile): record xprem_js_crash only for fatal JS errors
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:13:55 +01:00
jjbruntonandClaude Fable 5 659cee76b6 feat(mobile): add expo-observe update health tracking
Install expo-observe (56.0.29, matching the SDK 56 line) and configure
its endpoint under expo.extra.eas.observe in app.json, alongside the
existing EAS projectId. Leave dispatchInDebug and sampleRate at their
defaults.

Log an app_started custom event once per cold start from the root
layout's existing "run once" startup effect, and install a global JS
error handler that logs xprem_js_crash and then always delegates to
whatever handler was previously registered, so existing Crashlytics
reporting is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:11:43 +01:00
jjbruntonandClaude Fable 5 6bc4e31719 chore(mobile): upgrade to Expo SDK 56
Bump expo, react, react-native, and all expo-* / react-native-* peers to
the SDK 56 line via expo install --fix, adding expo-splash-screen and
expo-system-ui for config plugins the SDK split out of core. Bump
@bacons/apple-targets, react-native-view-shot, and other native modules
to versions compatible with React Native 0.85 and the new architecture.

Remove @react-navigation/* direct dependencies and route their hooks
through expo-router, which is no longer compatible with react-navigation
as of SDK 56. Fix RN 0.85's removed StyleSheet.absoluteFillObject (use
absoluteFill) and react-native-view-shot 5.x's ViewShotRef type across
call sites. Downgrade eslint-plugin-react-hooks v7's new React Compiler
era rules to warnings pending a dedicated refactor, and repair Jest
fallout from the jest-expo/reanimated/worklets/expo-media-library bumps.

Regenerate the committed ios/ and android/ native projects with
`expo prebuild --clean` (required for @bacons/apple-targets 5.x) against
the production release channel, and document that requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:11:37 +01:00
jjbruntonandClaude Opus 5 aa062c9fd0 fix(mobile): make ota-publish actually run under a path with spaces
The entrypoint guard compared process.argv[1] against
new URL(import.meta.url).pathname. A URL pathname percent-encodes spaces, so
in any checkout whose absolute path contains one the comparison never matched
and main() never ran.

Every ota:preflight:*, ota:publish:*, and ota:doctor:* script then exited 0
having done absolutely nothing, which is indistinguishable from success. A
publish that silently no-ops is worse than one that fails: it reports a
shipped update that never left the machine.

Compare resolved filesystem paths instead.

Verified from a checkout whose path contains a space: before the change
`node scripts/ota-publish.mjs production --preflight` exited 0 with no
output; after it runs a real export for both platforms and prints the
preflight confirmation. Argument validation still rejects unknown targets and
unsupported EOAS arguments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:09:38 +01:00
jjbrunton 5947220fc6 feat(release): add mobile build preflight 2026-08-31 10:18:20 +01:00
jjbrunton 8bf93f372b docs(release): authorize scoped store uploads 2026-08-31 10:08:03 +01:00
jjbrunton 2f05e9ae87 Merge pull request #21 from jjbrunton/staging
chore(release): publish v1.0.7 metadata
2026-08-31 01:29:58 +01:00
176 changed files with 55031 additions and 36352 deletions

No files matched your search

@@ -3,8 +3,8 @@ id: 2026-08-30-ios-extension-version-verification
date: 2026-08-30 date: 2026-08-30
status: promoted status: promoted
scope: iOS release artifact verification scope: iOS release artifact verification
evidence: build 45 Xcode archive warning plus direct PlistBuddy inspection of the exported IPA evidence: v1.0.6 exported IPA inspection and reproduced v1.0.7 Xcode build-setting mismatch
destination: .codex/skills/release-mobile/SKILL.md destination: apps/mobile/scripts/release-preflight.mjs and .codex/skills/release-mobile/SKILL.md
review_after: 2027-02-28 review_after: 2027-02-28
--- ---
@@ -21,19 +21,23 @@ and every packaged extension `Info.plist` with `PlistBuddy`.
## Cause ## Cause
The archive warning described an intermediate state and was not authoritative The v1.0.6 archive warning described an intermediate state and was not
for the exported package. Direct IPA inspection showed both the app and authoritative for that exported package. The later v1.0.7 reproduction found a
`LiveDrawWidget.appex` at version 1.0.6 build 45. separate real hazard: `CURRENT_PROJECT_VERSION` in both LiveDrawWidget
configurations can override the widget plist and package an older build number.
## Remediation ## Remediation
For every iOS release, inspect version and build values in the exported app and Before every iOS release, require public-version parity across app config, both
all packaged extensions. Reject or rebuild only when the final IPA values differ; plists, and Xcode marketing settings. Require the widget Debug and Release
do not accept or reject a release from archive warnings alone. `CURRENT_PROJECT_VERSION` values to equal both plists. Then inspect the exported
app and all packaged extensions. Reject or rebuild only when the final IPA values
differ; do not accept or reject a release from archive warnings alone.
## Evidence ## Evidence
`PlistBuddy` read `CFBundleShortVersionString` 1.0.6 and `CFBundleVersion` 45 from `PlistBuddy` read `CFBundleShortVersionString` 1.0.6 and `CFBundleVersion` 45 from
both packaged plists. `codesign --verify --deep --strict` passed for the app, and both v1.0.6 packaged plists. The v1.0.7 build later proved a stale widget Xcode
the verified IPA SHA-256 was setting can survive plist-only review. `release-preflight.mjs` and its node test
`9d4779bddac4647911a3a38e4ee37cf794c2b8691e0746a041b629c62b308e7f`. now reject that mismatch before EAS allocation; exported IPA inspection remains
the final authority.
@@ -0,0 +1,66 @@
---
id: 2026-08-31-expo-prebuild-native-regression
date: 2026-08-31
status: promoted
scope: apps/mobile native projects (ios/, android/) and expo prebuild
evidence: v1.0.8 SDK 54 to 56 upgrade, commits 604eec3, 7aca39a, 6bd04a6, 79233f5, b8cb2add
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, and docs/releasing.md
review_after: 2026-11-30
---
## Symptom
`expo prebuild --clean` during the SDK 54 to 56 upgrade (and the
@bacons/apple-targets 0.1 to 5.0 bump) silently regressed four checked-in
native invariants. Each regression was caught only by a release gate or by
manual diffing against the last shipped tag (3fc6f8a, v1.0.7), not by
prebuild itself.
## Reproduction
Run `expo prebuild --clean` in apps/mobile against the upgraded SDK and diff
the regenerated ios/ and android/ projects against the previously committed
projects.
## Cause
1. Widget identity: apple-targets 5.x derives PRODUCT_BUNDLE_IDENTIFIER from
the target folder name, changing com.sauci.app.LiveDrawWidget to
com.sauci.app.widget, and repointed CODE_SIGN_ENTITLEMENTS to the
gitignored ios/.targets/LiveDrawWidget/generated.entitlements.
2. Android manifest lost the tools:node="remove" attributes on
READ_MEDIA_IMAGES and READ_MEDIA_VIDEO, so the built AAB regained
Play-forbidden permissions (verified via bundletool dump on the rejected
build 50 AAB).
3. iOS Info.plist gained dev-launcher-only keys NSBonjourServices
(_expo._tcp), NSLocalNetworkUsageDescription, and RCTMetroPort.
4. android/gradle.properties reset to stock -Xmx2048m
-XX:MaxMetaspaceSize=512m, causing Metaspace OOM in kspReleaseKotlin/CMake
under RN 0.85.
Separately, a package absent from the new SDK's bundledNativeModules
(expo-av) has no compatible release and fails the iOS archive (EXAV.h
imports removed ExpoModulesCore headers).
## Remediation
Fixed by restoring widget identity and tracking
targets/widget/generated.entitlements with a pinned bundleIdentifier
(604eec3); restoring manifest tools:node="remove" attributes (7aca39a);
removing the dev-launcher-only Info.plist keys (6bd04a6); raising
gradle.properties memory to 4096m/1024m (79233f5); and migrating expo-av to
expo-video/expo-audio (b8cb2add).
Extend apps/mobile/scripts/release-preflight.mjs with executable checks for
items 2 and 3 (item 1 is already covered by the existing LiveDrawWidget
preflight check; item 4 hard-fails the build by itself). Add an "After
native regeneration" checklist to docs/releasing.md.
## Evidence
`node apps/mobile/scripts/release-preflight.mjs` fails if the tracked
AndroidManifest.xml lacks tools:node="remove" on READ_MEDIA_IMAGES or
READ_MEDIA_VIDEO, or if ios/Sauci/Info.plist contains NSBonjourServices,
NSLocalNetworkUsageDescription, or RCTMetroPort.
`node --test apps/mobile/scripts/release-preflight.test.mjs` covers the new
checks.
@@ -0,0 +1,55 @@
---
id: 2026-08-31-local-eas-build-preconditions
date: 2026-08-31
status: promoted
scope: apps/mobile local EAS release builds
evidence: v1.0.8 local EAS build session, commit d8bedea
destination: docs/releasing.md
review_after: 2026-11-30
---
## Symptom
Local EAS release builds failed before native compilation for environment
reasons unrelated to the app source, and a successful iOS build left the
tracked source in a state that fails release-preflight afterward.
## Reproduction
Run `eas build --local` from a non-interactive shell without first exporting
ANDROID_HOME/ANDROID_SDK_ROOT/JAVA_HOME: fails with "SDK location not
found" because non-interactive shells do not load the user profile. Run it
with a non-empty EAS_LOCAL_BUILD_WORKINGDIR: fails with "Workingdir is not
empty". Run it outside apps/mobile: eas cannot find the project. Pipe the
build log through `tail`: the full log needed for diagnosis is lost. After a
successful iOS build, EAS auto-increment updates both Info.plists but not
the widget target's CURRENT_PROJECT_VERSION in the pbxproj, so
release-preflight fails until it is synced.
## Cause
`eas` must be installed as a global CLI (scripts call bare `eas`; eas-cli
23.0.0 verified). Non-interactive shells used for automation do not source
the profile that normally exports the Android/Java environment variables.
EAS_LOCAL_BUILD_WORKINGDIR must not exist or be empty. EAS iOS
auto-increment writes plist versions but does not touch the widget target's
pbxproj CURRENT_PROJECT_VERSION, so it drifts from the app's build number by
one increment after every successful build.
## Remediation
Before a local EAS build: verify `eas` is on PATH, explicitly export
ANDROID_HOME, ANDROID_SDK_ROOT, and JAVA_HOME (JDK 17), ensure
EAS_LOCAL_BUILD_WORKINGDIR does not exist or is empty, and run from
apps/mobile. Capture the full build log to a file rather than piping through
`tail`. After a successful build, sync the widget's CURRENT_PROJECT_VERSION
to the new build number (commit d8bedea, sed 46 to 47) and sync
ios/Podfile.lock from the successful build's working directory when
dependencies changed, since the archive's pod install resolution is
authoritative and local CocoaPods here is broken.
## Evidence
docs/releasing.md's local-build section documents the environment
preconditions and the post-build widget CURRENT_PROJECT_VERSION and
Podfile.lock sync steps; `npm run release:preflight` passes after the sync.
@@ -0,0 +1,49 @@
---
id: 2026-08-31-mobile-release-preflight-and-attempts
date: 2026-08-31
status: promoted
scope: mobile production release preparation and local EAS builds
evidence: v1.0.7 Android and iOS local EAS release investigation and verified artifacts
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, docs/releasing.md, and release skills
review_after: 2027-02-28
---
## Symptom
The v1.0.7 release spent repeated attempts on archive, plugin startup, package
installation, disk-space, signing, and iOS extension-version failures before
valid Android and iOS artifacts were accepted.
## Reproduction
Run a local production EAS build with an ignored public OTA certificate, a
package-directory plugin path, insufficient disk space, or divergent widget
`CURRENT_PROJECT_VERSION`. Each condition fails or produces an invalid input
before a valid final artifact can be accepted.
## Cause
Pre-compilation setup failures were treated as rebuild failures. Source parity,
archive inclusion, local EAS plugin shape, disk capacity, and effective iOS widget
build settings were not checked together before EAS allocation.
## Remediation
Run the deterministic mobile release preflight before every production build.
Classify attempts before retrying: archive, plugin, npm, and ENOSPC failures do
not consume a native build; signing, profile, and capability failures require
provider read-back before one replacement build. Retain work directories and logs
at stable ignored paths. Accept only independently inspected artifacts and keep
rejected artifacts out of the upload path.
The Android native input failure was a Gradle task ordering issue: Prefab inputs
must be available before dependent native tasks. Preserve the explicit ordering
fix rather than removing Firebase or changing dependency versions without proof.
## Evidence
The preflight node tests reject an ignored OTA certificate and an effective widget
build-setting mismatch. The checked-in source invariants pass the preflight. The
maintained release guide and skills record local EAS path requirements, provider
read-back, phase-specific status language, artifact inspection, store read-back,
and scoped upload authorization boundaries.
@@ -0,0 +1,55 @@
---
id: 2026-08-31-npm-lockfile-divergence
date: 2026-08-31
status: promoted
scope: root package.json, package-lock.json, apps/web dependencies, CI
evidence: v1.0.8 lockfile investigation, commits 90cde4a, 9609a90
destination: docs/development/dependencies.md
review_after: 2026-11-30
---
## Symptom
CI and the API Docker image (node 20.19.4/npm 10.8.2, per .nvmrc) broke
invisibly while a lockfile regenerated on this dev machine (node 26/npm
11.19) passed local `npm ci`. Pushes to staging bypass required status
checks, so the red CI ran unnoticed for hours.
## Reproduction
Regenerate package-lock.json with npm 11 on a wildcard-range dependency
(react-native-worklets depends on `@react-native/metro-config: "*"`), then
run `npm ci` under node 20.19.4/npm 10.8.2. npm 10 requires 0.85.3 at that
tree position; npm 11 resolves/hoists 0.87.x there. `npm ci` fails with
"Invalid: lock file's ... does not satisfy". The reverse direction also
fails: a lock regenerated by npm 10 fails `npm ci` under npm 11 with missing
platform-optional binaries (@next/swc-linux-x64-gnu breaking `next build`,
@rollup/rollup-linux-x64-gnu breaking the API Docker tsup build, lightningcss
variants breaking local npm ci), matching the npm/cli#4828-style
optional-dependency resolution gap.
## Cause
Two different npm major versions in active use (CI/Docker on npm 10, this
dev machine on npm 11) resolve wildcard/range dependencies to different
versions at the same tree position, and each npm's own platform-optional
binary selection is not portable to the other npm's lockfile.
## Remediation
Pin the wildcard-range dependency via a root package.json override
(`"@react-native/metro-config": "0.85.3"`, alongside the existing
rollup/react-native/expo pins). Pin apps/web `@next/swc` linux/darwin
binaries as direct optionalDependencies (must be bumped together with any
`next` version bump). Regenerate the lock and validate it both ways: local
`npm ci` plus `npm run verify:fast`, and
`docker run --platform linux/amd64 node:20.19.4 ... npm ci` (the
`--platform` flag is mandatory; Apple Silicon Docker otherwise runs arm64
and the platform-binary check is meaningless). After any push that bypassed
required status checks, check `gh run list --branch staging`.
## Evidence
commits 90cde4a and 9609a90 add the override and optionalDependencies pins
and record a lockfile that passes both the amd64-emulated `npm ci` under
node 20.19.4 and local `npm ci` plus `npm run verify:fast`.
@@ -0,0 +1,40 @@
---
id: 2026-09-05-question-type-contract-drift
date: 2026-09-05
status: promoted
scope: question interaction contract documentation
evidence: npm run lint:harness and npm run learn:check
destination: docs/question-types.md, scripts/lint-harness.mjs, AGENTS.md, and .codex/skills/question-pack/SKILL.md
review_after: 2026-12-05
---
## Symptom
Maintained documentation did not identify the implemented question interaction
types, their response payloads, or their matching semantics. The only detailed
reference was a historical implementation plan, so content and product work
could not rely on maintained documentation.
## Reproduction
Compare `QuestionType` in `packages/shared/src/types/index.ts` and the question
renderer and answer matching code with `docs/index.md`, `docs/schema.md`, and
`docs/question-selection.md`.
## Cause
The interaction contract was added to code without a maintained documentation
owner or a verification check linking the document to the canonical union.
## Remediation
Create `docs/question-types.md` as the maintained interaction contract. Require
agents and the question-pack workflow to update it with type or semantic
changes. Add a harness invariant that compares its type marker to the shared
`QuestionType` union.
## Evidence
`npm run lint:harness` reads the shared union and the documentation marker and
fails when their ordered type lists differ. `npm run learn:check` validates this
promoted learning record and its destination metadata.
+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
name: release-mobile name: release-mobile
description: Prepare and verify Sauci iOS or Android releases and store uploads. description: Prepare, verify, and ship Sauci iOS or Android releases end to end, including store uploads, review submission, and rollout.
--- ---
Read `.codex/skills/release-mobile/SKILL.md` completely and follow it. Read `.codex/skills/release-mobile/SKILL.md` completely and follow it.
+26 -7
View File
@@ -13,9 +13,13 @@ drafting notes and [references/artifact-verification.md](references/artifact-ver
before accepting a build. before accepting a build.
A request to generate or create a release authorizes the scoped version edits, A request to generate or create a release authorizes the scoped version edits,
release notes, local builds, release commits, and one local annotated tag. It release notes, local builds, release commits, and one local annotated tag. When
does not authorize pushing commits or tags, uploading binaries, submitting a the request also asks to ship, upload, submit, or release to the stores, it
store release, changing signing credentials, or regenerating native projects. authorizes the complete store pipeline for the stated platforms after the exact
artifact has been verified: upload, App Review / Google Play review submission,
and rollout, including full production rollout (see the `release-mobile` skill).
It does not authorize pushing commits or tags, changing signing credentials, or
regenerating native projects unless the request explicitly includes that action.
## Defaults ## Defaults
@@ -39,6 +43,8 @@ Require all of the following:
- consistent public and OTA runtime versions across `app.json`, explicit - consistent public and OTA runtime versions across `app.json`, explicit
`app.config.js` runtime, Android, and every iOS target `app.config.js` runtime, Android, and every iOS target
(`scripts/set_version.mjs --check`); (`scripts/set_version.mjs --check`);
- `npm run release:preflight -w @sauci/mobile`, which also checks the iOS widget
effective build settings and OTA certificate archive inclusion;
- Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS; - Node 20, JDK 17 for Android, and Xcode/CocoaPods for iOS;
- the configured production API/Auth/RevenueCat environment, without printing - the configured production API/Auth/RevenueCat environment, without printing
secret values. secret values.
@@ -80,6 +86,15 @@ Build from the clean prepared source using the package scripts in
versioned artifact directory. When building both platforms, build Android first, versioned artifact directory. When building both platforms, build Android first,
then iOS. then iOS.
For local EAS, retain the working directory and logs under a stable ignored path,
set `EAS_LOCAL_BUILD_ARTIFACTS_DIR`, and preflight available disk space before
starting. Set `EAS_LOCAL_BUILD_PLUGIN_PATH` only to the matching plugin
executable `bin/run`, never a package directory or a hardcoded cache location.
Classify plugin, archive, npm, and ENOSPC failures as pre-compilation failures;
repair and preflight them without spending another build. Make at most one
meaningful build for each newly proven input state. After a signing, profile, or
capability failure, read back and prove the provider correction before rebuilding.
After each build: After each build:
1. Inspect the worktree before doing anything else. Accept only the documented 1. Inspect the worktree before doing anything else. Accept only the documented
@@ -88,15 +103,19 @@ After each build:
dependency, or native-project regeneration as a failed build. dependency, or native-project regeneration as a failed build.
2. Independently inspect the binary for package/bundle identifier, public 2. Independently inspect the binary for package/bundle identifier, public
version, platform build number, production endpoint configuration where version, platform build number, production endpoint configuration where
inspectable, and signing identity/fingerprint. Require the embedded public inspectable, signing identity/fingerprint, OTA runtime/channel/certificate,
version to equal the requested version. and SHA-256. Require the embedded public version to equal the requested
version. For Android, also inspect all required ABIs, Worklets/Reanimated/Skia
libraries, and the release permissions. For iOS, inspect every packaged
extension after export.
3. Record the verified platform build number in the new release-note entry. 3. Record the verified platform build number in the new release-note entry.
4. Commit only the expected metadata and release-note update as 4. Commit only the expected metadata and release-note update as
`chore(<platform>): record v<version> build <number>`. The worktree must be `chore(<platform>): record v<version> build <number>`. The worktree must be
clean before starting the next platform build. clean before starting the next platform build.
Do not accept a successful EAS exit code as proof of a valid artifact. If either Do not accept a successful EAS exit code as proof of a valid artifact. Quarantine
requested platform fails, do not tag a partial release. or remove rejected artifacts so only accepted paths can be offered for upload. If
either requested platform fails, do not tag a partial release.
## Tag and prove the result ## Tag and prove the result
+5
View File
@@ -7,6 +7,11 @@ description: Create or review Sauci question-pack content and its local migratio
Inspect existing packs before generating to avoid duplication. Inspect existing packs before generating to avoid duplication.
Before creating or editing content that uses a question interaction type, read
`docs/question-types.md`. If the type, response payload, configuration, or
matching semantics change, update that maintained contract and run
`npm run lint:harness` in the same task.
- Write proposals, not interview questions; use gender-neutral “your partner”. - Write proposals, not interview questions; use gender-neutral “your partner”.
- Avoid time-bound words such as tonight, now, or today. - Avoid time-bound words such as tonight, now, or today.
- Symmetric activities have no partner text. - Symmetric activities have no partner text.
+114 -29
View File
@@ -1,6 +1,6 @@
--- ---
name: release-mobile name: release-mobile
description: Prepare and verify Sauci iOS or Android releases. Use for versioning, EAS/native builds, Fastlane upload, release notes, or store submission preparation. description: Prepare, verify, and ship Sauci iOS or Android releases end to end. Use for versioning, EAS/native builds, store uploads, App Review / Play review submission, and rollout.
--- ---
# Mobile release # Mobile release
@@ -9,6 +9,41 @@ Read `docs/releasing.md`, `apps/mobile/RELEASING.md` when present, and the mobil
scoped instructions. Confirm target platform and release profile. Run repository scoped instructions. Confirm target platform and release profile. Run repository
verification plus a native acceptance pass before starting a release build. verification plus a native acceptance pass before starting a release build.
## OTA update vs full store release
Choose the delivery mechanism before doing anything else; they are different
pipelines with different blast radii. `docs/mobile-ota.md` is the authority.
- **OTA update** (`npm run ota:publish:<env>` in `apps/mobile`): ships only the
JavaScript bundle and assets to builds already installed on devices. No store
review, live within minutes, and rolled back by repointing the XPREM channel
at a previous update. Valid only when the change touches no native code, no
native dependency, no `ios/`/`android/` project files, and no config that
alters the native binary. The OTA runtime version equals the public app
version, so an OTA update can only reach installs of the current
`expo.version`.
- **Full store release** (this skill): a new signed binary through App Store
Connect and Google Play, including review. Required whenever native code,
native dependencies, the checked-in native projects, Expo SDK, permissions,
entitlements, or `expo.version` change. Rollback is coarser: Play staged
rollouts can be halted, iOS phased release can be paused, and a bad release is
otherwise superseded by shipping the next version.
When a change qualifies for OTA, prefer OTA unless the user asks for a store
release. Never present an OTA publish as a store release or vice versa; report
which mechanism was used.
## Authorization
An explicit request for a mobile release authorizes the complete pipeline for
the stated platforms: version bump, build, verification, store upload, review
submission (App Review / Google Play review), and rollout — including full
production rollout. Rollout is recoverable by design (staged-rollout halt,
phased-release pause, OTA supersession, or the next store version), so do not
pause to re-confirm these steps; execute them and report. A request that
explicitly narrows scope (for example "upload only", "TestFlight only", or a
named track) still bounds what you do.
Before a build from the current upstream branch, fetch, require a clean worktree, Before a build from the current upstream branch, fetch, require a clean worktree,
and prove `HEAD` matches its upstream. The `generate-release` skill may instead and prove `HEAD` matches its upstream. The `generate-release` skill may instead
build a clean, scoped release-preparation commit that descends directly from that build a clean, scoped release-preparation commit that descends directly from that
@@ -17,40 +52,65 @@ verified EAS version metadata. Sauci tracks its native iOS and Android projects,
so routine release builds must package those checked-in sources. Do not run so routine release builds must package those checked-in sources. Do not run
`expo prebuild` first; native regeneration is a separate reviewed change. `expo prebuild` first; native regeneration is a separate reviewed change.
Use the package scripts in `apps/mobile/package.json`; they create artifacts only. Run `npm run release:preflight -w @sauci/mobile` before allocating an EAS build.
Never infer upload or submission authority from build authorization. Store upload It rejects inconsistent public/runtime versions, an iOS widget build-setting
uses a separately authorized command. override, and a public OTA certificate that would be omitted from the archive.
For a local EAS build, also run `npm run release:preflight:local-eas -w
@sauci/mobile` after setting `EAS_LOCAL_BUILD_PLUGIN_PATH` to the matching
plugin's executable `bin/run`. Do not point it at the package directory or
hardcode a machine-local cache path.
## Upload authorization gate Classify a failure before rebuilding. Archive, plugin-spawn, package-install, and
disk-space failures happen before native compilation and must be repaired and
preflighted without consuming another build. After a signing, profile, or
capability failure, read back the provider state and prove only the affected
input changed before rebuilding. Retain the EAS work directory, command log, and
stable artifact path for every meaningful attempt. Never retry an ambiguous
upload.
After an artifact passes verification, stop immediately before the external Report provider progress by phase: packaging, native compilation, submission
upload and ask the user for confirmation. The request must identify: queue, or store processing. Do not describe every failure as a rebuild.
Use the package scripts in `apps/mobile/package.json` to build artifacts and the
Fastlane lanes in `apps/mobile/fastlane/Fastfile` to upload, submit, and roll
out.
## Upload and submission execution
Verification before upload is unchanged and mandatory. Independently verify and
report:
- the platform, exact artifact path, bundle/package identifier, public version, - the platform, exact artifact path, bundle/package identifier, public version,
build number, signature identity, and SHA-256 digest; build number, signature identity, and SHA-256 digest;
- the exact destination and resulting state: Google Play internal-track draft or - the exact destination and resulting state: which Play track and rollout
App Store Connect/TestFlight processing; status, or App Store Connect version state and whether App Review submission
- that the upload does not authorize a public rollout or App Review submission. and automatic release are included.
For iOS, inspect the current App Store Connect automatic-distribution behavior Once these details match the request, proceed through upload, review
before asking. If the build would be distributed automatically, name the exact submission, and rollout without requesting further confirmation.
TestFlight groups or testers in the request and include that effect in the fresh
authorization. If the effect cannot be established, stop; changing an automatic-
distribution setting also requires separate authorization.
Even when the original request includes building, releasing, uploading, or - **Android**: `bundle exec fastlane android release aab:<absolute path>`
submitting, require this fresh confirmation once the exact verified artifact and commits the release on the production track (pass `track:` to target another
destination are known. End the request with: `Upload this verified artifact to track, `rollout:0.1` for a staged rollout). Play review runs automatically;
<destination> now?` A clear affirmative response authorizes one upload of that rollout proceeds on approval. Use the `upload` lane only when the request is
artifact to that destination, plus only any automatic TestFlight distribution explicitly upload-only.
explicitly disclosed in the request. Run `eas submit` with the explicit - **iOS**: `bundle exec fastlane ios release ipa:<absolute path>` uploads,
`--platform`, `--profile production`, `--path`, and `--wait` arguments; never creates the App Store version, submits for App Review, and releases
select `--latest`. If the result is ambiguous, inspect EAS and the store before automatically on approval (pass `phased_release:true` for a 7-day phased
doing anything else, do not retry, and ask again before another upload. release, `release_notes:"..."` for What's New). Use the `upload` lane for
TestFlight-only requests. `eas submit` remains valid for upload-only flows;
when used, pass explicit `--platform`, `--profile production`, `--path`, and
`--wait` — never `--latest`.
Apply this gate to every upload method, including EAS, Fastlane, Xcode Organizer, App Store Connect API group association does not establish the automatic-
and the store consoles. A later public rollout, App Review submission, or distribution switch. Verify that switch in an authenticated App Store Connect UI
additional TestFlight distribution requires another fresh authorization. session before upload; if that UI verification is unavailable, stop rather
than inferring the distribution effect from API data.
If an upload or submission command returns an ambiguous result, inspect EAS and
the store before doing anything else; do not retry blind. After submission, read
back the store state (Play Console track status / App Store Connect version
state) and report it.
Preserve platform signing and local EAS versioning. After a build, verify that Preserve platform signing and local EAS versioning. After a build, verify that
tracked changes are limited to the expected version metadata, then independently tracked changes are limited to the expected version metadata, then independently
@@ -58,5 +118,30 @@ read the artifact version and signature. For an iOS IPA, inspect every packaged
app extension as well as the containing app and require matching app extension as well as the containing app and require matching
`CFBundleShortVersionString` and `CFBundleVersion` values. Treat Xcode archive `CFBundleShortVersionString` and `CFBundleVersion` values. Treat Xcode archive
warnings as diagnostic evidence, not the final artifact state: decide whether to warnings as diagnostic evidence, not the final artifact state: decide whether to
reject or rebuild only after reading the exported IPA itself. Report the artifact, reject or rebuild only after reading the exported IPA itself. Report the
version/build number, upload result when authorized, and store read-back state. artifact, version/build number, upload and submission results, rollout state,
and store read-back state.
For Android, verify the accepted AAB has all four required ABIs, the Worklets,
Reanimated, and Skia native libraries, no broad `READ_MEDIA_IMAGES` or
`READ_MEDIA_VIDEO` permissions, and the production OTA runtime, channel, and
public certificate. For iOS, verify the exported IPA, not archive warnings, and
check both the containing app and every extension.
For the Android native dependency ordering already tracked in Gradle, preserve
the explicit task dependency that makes Prefab inputs available before dependent
native tasks. Fix task validation with ordering, not Firebase removal or
dependency-version roulette.
After Android upload, EAS Submit `FINISHED`, the exact internal/DRAFT/no-rollout
submit configuration, and the retained artifact digest are the available
authoritative read-back when a direct Play track read would require creating an
edit. Never create an edit merely to claim read-only verification.
For a checked-in iOS entitlement, inspect the real Apple App ID capability state
before a signing rebuild. The capability sync bug tracked in
https://github.com/expo/eas-cli/issues/3986 can report success while Apple
rejects a malformed update. Read back Associated Domains, regenerate only the
affected profile, and decode that profile to prove the entitlement before
rebuilding. Noninteractive managed App Store Connect key auth may require
`EXPO_APPLE_TEAM_ID`; preserve certificates and unrelated target profiles.
+8
View File
@@ -39,3 +39,11 @@ terraform
.claude .claude
.codex .codex
.ralph .ralph
# The admin app builds from this same monorepo context (apps/admin/Dockerfile).
!apps/admin
!apps/admin/**
# Host build artefacts must never leak into any image context.
**/node_modules
**/dist
+8 -3
View File
@@ -24,9 +24,14 @@ jobs:
echo "master is frozen; target staging or main" >&2 echo "master is frozen; target staging or main" >&2
exit 1 exit 1
fi fi
if [ "$BASE_BRANCH" = "main" ] && [ "$HEAD_BRANCH" != "staging" ]; then if [ "$BASE_BRANCH" = "main" ]; then
echo "Only staging may be promoted to main" >&2 case "$HEAD_BRANCH" in
exit 1 staging|hotfix/*|release/*) ;;
*)
echo "main takes staging promotions, or a hotfix/* or release/* branch for direct work" >&2
exit 1
;;
esac
fi fi
if [ "$BASE_BRANCH" != "staging" ] && [ "$BASE_BRANCH" != "main" ]; then if [ "$BASE_BRANCH" != "staging" ] && [ "$BASE_BRANCH" != "main" ]; then
echo "Feature pull requests target staging; promotions target main" >&2 echo "Feature pull requests target staging; promotions target main" >&2
+8
View File
@@ -40,6 +40,10 @@ MCP service in one npm/Turborepo workspace.
11. Use high-effort reviewers only for the risk triggers in 11. Use high-effort reviewers only for the risk triggers in
`docs/agents/routing.md`. `docs/agents/routing.md`.
12. Agents must not use em dash characters in text they add to this repository. 12. Agents must not use em dash characters in text they add to this repository.
13. Before beginning implementation, tell the user whether the proposed change
is likely deliverable through an Expo OTA update, likely requires a new
native/store build, or has no applicable OTA path, with a brief reason. If
uncertain, state that uncertainty and name the validation needed.
## Repository map ## Repository map
@@ -76,6 +80,7 @@ environment limitation; do not call it a passing product check.
|---|---| |---|---|
| System boundaries and data flow | `docs/architecture/system.md` | | System boundaries and data flow | `docs/architecture/system.md` |
| Local services, ports, fixtures | `docs/development/local-stack.md` | | Local services, ports, fixtures | `docs/development/local-stack.md` |
| Dependency and lockfile discipline | `docs/development/dependencies.md` |
| Test layers and required gates | `docs/testing/strategy.md` | | Test layers and required gates | `docs/testing/strategy.md` |
| Database migration safety | `docs/operations/database-migrations.md` | | Database migration safety | `docs/operations/database-migrations.md` |
| Agent roles and effort routing | `docs/agents/routing.md` | | Agent roles and effort routing | `docs/agents/routing.md` |
@@ -86,6 +91,9 @@ environment limitation; do not call it a passing product check.
## Documentation discipline ## Documentation discipline
Update the maintained doc when a contract, command, port, or invariant changes. Update the maintained doc when a contract, command, port, or invariant changes.
When question interaction types, response payloads, configuration, or match
semantics change, update `docs/question-types.md` in the same change and run
`npm run lint:harness` to prove its type list matches the shared contract.
Plans and historical notes are evidence, not current architecture. A repeated Plans and historical notes are evidence, not current architecture. A repeated
lesson becomes permanent only after it is reproduced and promoted into a doc, lesson becomes permanent only after it is reproduced and promoted into a doc,
skill, fixture, or executable check. skill, fixture, or executable check.
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { featureInterestKey, formatFeatureName, toRecentFeatureInterest } from './featureInterests';
describe('toRecentFeatureInterest', () => {
it('preserves the standalone API feature field for dashboard rendering', () => {
const interest = toRecentFeatureInterest({
user_id: 'user-1',
feature: 'better_chat',
created_at: '2026-09-05T12:00:00.000Z',
});
expect(interest).toEqual({
user_id: 'user-1',
feature: 'better_chat',
created_at: '2026-09-05T12:00:00.000Z',
});
expect(formatFeatureName(interest.feature)).toBe('Better Chat');
expect(featureInterestKey(interest)).toBe('user-1:better_chat');
});
});
+34
View File
@@ -0,0 +1,34 @@
export interface FeatureInterestRow {
user_id: string;
feature: string;
created_at: string;
}
export interface RecentFeatureInterest extends FeatureInterestRow {
profile?: {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
};
}
export function toRecentFeatureInterest(row: FeatureInterestRow): RecentFeatureInterest {
return {
user_id: row.user_id,
feature: row.feature,
created_at: row.created_at,
};
}
export function featureInterestKey(row: Pick<FeatureInterestRow, 'user_id' | 'feature'>): string {
return `${row.user_id}:${row.feature}`;
}
export function formatFeatureName(feature: string): string {
return feature
.split('_')
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
+11 -30
View File
@@ -10,36 +10,22 @@ import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recha
import { format, subDays, startOfDay, endOfDay, formatDistanceToNow } from 'date-fns'; import { format, subDays, startOfDay, endOfDay, formatDistanceToNow } from 'date-fns';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import {
formatFeatureName,
featureInterestKey,
toRecentFeatureInterest,
type FeatureInterestRow,
type RecentFeatureInterest,
} from '@/lib/featureInterests';
type FeatureInterestChartPoint = { date: string; count: number }; type FeatureInterestChartPoint = { date: string; count: number };
type RecentFeatureInterest = {
id: string;
user_id: string;
feature_name: string;
created_at: string;
profile?: {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
};
};
type FeatureInterestCountsRow = { type FeatureInterestCountsRow = {
feature_name: string; feature_name: string;
opt_in_count: number; opt_in_count: number;
opt_in_count_last_7_days: number; opt_in_count_last_7_days: number;
}; };
function formatFeatureName(featureName: string) {
return featureName
.split('_')
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export function DashboardPage() { export function DashboardPage() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS); const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
@@ -95,7 +81,7 @@ export function DashboardPage() {
? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString()) ? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
: Promise.resolve({ data: [] }), : Promise.resolve({ data: [] }),
canViewUsers canViewUsers
? adminData.from('feature_interests').select('id, user_id, feature_name, created_at').order('created_at', { ascending: false }).limit(10) ? adminData.from<FeatureInterestRow>('feature_interests').select('user_id, feature, created_at').order('created_at', { ascending: false }).limit(10)
: Promise.resolve({ data: [] }), : Promise.resolve({ data: [] }),
]); ]);
@@ -158,12 +144,7 @@ export function DashboardPage() {
})) }))
); );
let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(item => ({ let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(toRecentFeatureInterest);
id: item.id,
user_id: item.user_id,
feature_name: item.feature_name,
created_at: item.created_at,
}));
if (canViewUsers && recentActivity.length > 0) { if (canViewUsers && recentActivity.length > 0) {
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id))); const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
@@ -579,14 +560,14 @@ export function DashboardPage() {
if (item.profile?.name && item.profile.email) { if (item.profile?.name && item.profile.email) {
subtitleParts.push(item.profile.email); subtitleParts.push(item.profile.email);
} }
subtitleParts.push(formatFeatureName(item.feature_name)); subtitleParts.push(formatFeatureName(item.feature));
const fallbackChar = (item.profile?.name || item.profile?.email || 'U') const fallbackChar = (item.profile?.name || item.profile?.email || 'U')
.charAt(0) .charAt(0)
.toUpperCase(); .toUpperCase();
return ( return (
<div key={item.id} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3"> <div key={featureInterestKey(item)} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage src={item.profile?.avatar_url || undefined} /> <AvatarImage src={item.profile?.avatar_url || undefined} />
@@ -0,0 +1,317 @@
-- Initial editorial dare catalogue. Catalogue IDs are deterministic so the seed
-- is replay-safe. Only rows that passed the universal store-safety review are
-- public and `allowed`; all other editorial inventory remains fail-closed.
WITH seed (
id, name, description, icon, is_premium, is_public, is_explicit, sort_order,
min_intensity, max_intensity, avg_intensity, content_status, content_review_reason
) AS (VALUES
('1f5e0000-0000-4000-8000-100000000001', 'Little Things', 'Small gestures that make your partner feel seen.', '💌', false, true, false, 10, 1, 1, 1.00, 'allowed', 'Reviewed as non-sexual relationship content'),
('1f5e0000-0000-4000-8000-100000000002', 'Make Me Laugh', 'Silly challenges for a shared laugh.', '😄', false, true, false, 20, 1, 2, 1.33, 'allowed', 'Reviewed as non-sexual relationship content'),
('1f5e0000-0000-4000-8000-100000000003', 'Sweet & Romantic', 'Thoughtful dares for feeling close.', '💕', false, true, false, 30, 1, 2, 1.80, 'allowed', 'Reviewed as non-sexual romantic content'),
('1f5e0000-0000-4000-8000-100000000004', 'Surprise Me', 'Plan a little surprise for your partner.', '🎁', true, true, false, 40, 1, 2, 1.80, 'allowed', 'Reviewed as non-sexual relationship content'),
('1f5e0000-0000-4000-8000-100000000005', 'Flirty Messages', 'Private messages with a little spark.', '💬', true, false, false, 50, 2, 3, 2.47, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-100000000006', 'Long-Distance Heat', 'Private dares for partners apart.', '📱', true, false, true, 60, 2, 3, 2.67, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-100000000007', 'Risky Photos & Audio', 'Private photo and audio dares.', '📸', true, false, true, 70, 3, 4, 3.53, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-100000000008', 'Hands On', 'Private in-person intimacy dares.', '🫶', true, false, true, 80, 3, 4, 3.40, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-100000000009', 'After Dark', 'Private adult intimacy dares.', '🌙', true, false, true, 90, 4, 4, 4.00, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-100000000010', 'Power Play', 'Private adult power-play dares.', '🔒', true, false, true, 100, 5, 5, 5.00, 'unreviewed', 'Private mature editorial inventory')
), upsert AS (
INSERT INTO dare_packs (
id, name, description, icon, is_premium, is_public, is_explicit, sort_order,
min_intensity, max_intensity, avg_intensity, content_status, content_review_reason,
content_reviewed_at
)
SELECT id::uuid, name, description, icon, is_premium, is_public, is_explicit, sort_order,
min_intensity, max_intensity, avg_intensity::numeric(3, 2),
content_status::content_review_status, content_review_reason,
CASE WHEN content_status = 'allowed' THEN '2026-09-05T16:00:00Z'::timestamptz END
FROM seed
ON CONFLICT (id) DO UPDATE SET id = dare_packs.id
WHERE (dare_packs.name, dare_packs.description, dare_packs.icon,
dare_packs.is_premium, dare_packs.is_public, dare_packs.is_explicit,
dare_packs.sort_order, dare_packs.category_id, dare_packs.min_intensity,
dare_packs.max_intensity, dare_packs.avg_intensity, dare_packs.content_status,
dare_packs.content_review_reason, dare_packs.content_reviewed_at,
dare_packs.content_reviewed_by)
IS NOT DISTINCT FROM
(EXCLUDED.name, EXCLUDED.description, EXCLUDED.icon, EXCLUDED.is_premium,
EXCLUDED.is_public, EXCLUDED.is_explicit, EXCLUDED.sort_order,
EXCLUDED.category_id, EXCLUDED.min_intensity, EXCLUDED.max_intensity,
EXCLUDED.avg_intensity, EXCLUDED.content_status,
EXCLUDED.content_review_reason, EXCLUDED.content_reviewed_at,
EXCLUDED.content_reviewed_by)
RETURNING id
)
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
--> statement-breakpoint
WITH seed (id, pack_id, text, intensity, content_status, content_review_reason) AS (VALUES
-- Little Things
('1f5e0000-0000-4000-8000-000000000001', '1f5e0000-0000-4000-8000-100000000001', 'Send your partner a photo of something that reminded you of them.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000002', '1f5e0000-0000-4000-8000-100000000001', 'Hide a short note somewhere your partner will unexpectedly find it.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000003', '1f5e0000-0000-4000-8000-100000000001', 'Bring your partner their favourite snack without asking what they want.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000004', '1f5e0000-0000-4000-8000-100000000001', 'Take over the household job your partner hates most.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000005', '1f5e0000-0000-4000-8000-100000000001', 'Make your partner a drink exactly how they like it.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000006', '1f5e0000-0000-4000-8000-100000000001', 'Send your partner a compliment that has nothing to do with their appearance.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000007', '1f5e0000-0000-4000-8000-100000000001', 'Give your partner a proper uninterrupted hug.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000008', '1f5e0000-0000-4000-8000-100000000001', 'Choose a shared photo and tell your partner why you still love that moment.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000009', '1f5e0000-0000-4000-8000-100000000001', 'Make your partner a three-song playlist for their current mood.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000010', '1f5e0000-0000-4000-8000-100000000001', 'Leave your partner a voice note they can replay when they are having a bad day.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000011', '1f5e0000-0000-4000-8000-100000000001', 'Give your partner a homemade voucher for one chore-free evening.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000012', '1f5e0000-0000-4000-8000-100000000001', 'Put your phone away and give your partner your full attention for thirty minutes.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000013', '1f5e0000-0000-4000-8000-100000000001', 'Tell your partner about the first moment you realised they were special.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000014', '1f5e0000-0000-4000-8000-100000000001', 'Fix one small annoyance your partner has been putting off.', 1, 'allowed', 'Reviewed with Little Things pack'),
('1f5e0000-0000-4000-8000-000000000015', '1f5e0000-0000-4000-8000-100000000001', 'Let your partner choose what you watch, play, or listen to together.', 1, 'allowed', 'Reviewed with Little Things pack'),
-- Make Me Laugh
('1f5e0000-0000-4000-8000-000000000016', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner the worst selfie you can take.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000017', '1f5e0000-0000-4000-8000-100000000002', 'Record a ten-second impression of your partner.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000018', '1f5e0000-0000-4000-8000-100000000002', 'Draw your partner from memory and send them the result.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000019', '1f5e0000-0000-4000-8000-100000000002', 'Make a meme about your relationship.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000020', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner your cheesiest pickup line.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000021', '1f5e0000-0000-4000-8000-100000000002', 'Invent a ridiculous new nickname for your partner and use it until the dare expires.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000022', '1f5e0000-0000-4000-8000-100000000002', 'Record a dramatic advert explaining why someone should date your partner.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000023', '1f5e0000-0000-4000-8000-100000000002', 'Tell your partner a painfully bad joke without laughing.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000024', '1f5e0000-0000-4000-8000-100000000002', 'Recreate one of your partner''s photos as badly as possible.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000025', '1f5e0000-0000-4000-8000-100000000002', 'Describe your relationship using only five emojis.', 1, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000026', '1f5e0000-0000-4000-8000-100000000002', 'Perform a thirty-second dance chosen by your partner.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000027', '1f5e0000-0000-4000-8000-100000000002', 'Wear a ridiculous accessory until your partner releases you from the dare.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000028', '1f5e0000-0000-4000-8000-100000000002', 'Serenade your partner with the least romantic song you can find.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000029', '1f5e0000-0000-4000-8000-100000000002', 'Send your partner an embarrassing but harmless confession.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
('1f5e0000-0000-4000-8000-000000000030', '1f5e0000-0000-4000-8000-100000000002', 'Act out how you behaved on your first date.', 2, 'allowed', 'Reviewed with Make Me Laugh pack'),
-- Sweet & Romantic
('1f5e0000-0000-4000-8000-000000000031', '1f5e0000-0000-4000-8000-100000000003', 'Write your partner a love note by hand.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000032', '1f5e0000-0000-4000-8000-100000000003', 'Send your partner a song that says what you struggle to put into words.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000033', '1f5e0000-0000-4000-8000-100000000003', 'Tell your partner three small things they do that make your life better.', 1, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000034', '1f5e0000-0000-4000-8000-100000000003', 'Plan an at-home date and keep the details secret.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000035', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner a slow dance without waiting for the perfect song.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000036', '1f5e0000-0000-4000-8000-100000000003', 'Recreate one detail from your first date together.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000037', '1f5e0000-0000-4000-8000-100000000003', 'Kiss your partner like you have not seen them for a month.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000038', '1f5e0000-0000-4000-8000-100000000003', 'Send your partner a voice note describing your favourite memory of them.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000039', '1f5e0000-0000-4000-8000-100000000003', 'Prepare your partner''s side of the bed exactly how they like it.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000040', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner five kisses, each in a different place.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000041', '1f5e0000-0000-4000-8000-100000000003', 'Tell your partner the quality you find most attractive in them.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000042', '1f5e0000-0000-4000-8000-100000000003', 'Plan a mystery date and give your partner only one clue.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000043', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner a compliment they will still remember next week.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000044', '1f5e0000-0000-4000-8000-100000000003', 'Read your partner a message you sent during the early days of your relationship.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
('1f5e0000-0000-4000-8000-000000000045', '1f5e0000-0000-4000-8000-100000000003', 'Give your partner ten minutes of affection without looking at either of your phones.', 2, 'allowed', 'Reviewed with Sweet & Romantic pack'),
-- Surprise Me
('1f5e0000-0000-4000-8000-000000000046', '1f5e0000-0000-4000-8000-100000000004', 'Leave your partner''s favourite treat somewhere they will discover it.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000047', '1f5e0000-0000-4000-8000-100000000004', 'Complete one job your partner expects to do themselves.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000048', '1f5e0000-0000-4000-8000-100000000004', 'Send your partner an unexpected food delivery.', 1, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000049', '1f5e0000-0000-4000-8000-100000000004', 'Arrange a mystery date and tell your partner only when to be ready.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000050', '1f5e0000-0000-4000-8000-100000000004', 'Hide three clues leading your partner to a small surprise.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000051', '1f5e0000-0000-4000-8000-100000000004', 'Take your partner somewhere neither of you has visited before.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000052', '1f5e0000-0000-4000-8000-100000000004', 'Give your partner flowers or their preferred equivalent for no particular reason.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000053', '1f5e0000-0000-4000-8000-100000000004', 'Send your partner coordinates and ask them to meet you there.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000054', '1f5e0000-0000-4000-8000-100000000004', 'Create a playlist that reveals what you have planned.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000055', '1f5e0000-0000-4000-8000-100000000004', 'Recreate your partner''s ideal lazy morning.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000056', '1f5e0000-0000-4000-8000-100000000004', 'Put together a date using only things already in your home.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000057', '1f5e0000-0000-4000-8000-100000000004', 'Choose an outfit for your partner from clothes they already own.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000058', '1f5e0000-0000-4000-8000-100000000004', 'Give your partner an envelope they are not allowed to open until the dare expires.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000059', '1f5e0000-0000-4000-8000-100000000004', 'Arrange a surprise based on something your partner mentioned weeks ago.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
('1f5e0000-0000-4000-8000-000000000060', '1f5e0000-0000-4000-8000-100000000004', 'Turn an ordinary meal into a date without warning your partner first.', 2, 'allowed', 'Reviewed with Surprise Me pack'),
-- Flirty Messages
('1f5e0000-0000-4000-8000-000000000061', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner three messages that become progressively flirtier.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000062', '1f5e0000-0000-4000-8000-100000000005', 'Record yourself saying your partner''s name in the most seductive way you can.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000063', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a selfie wearing something you know they like.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000064', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner where you want them to kiss you.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000065', '1f5e0000-0000-4000-8000-100000000005', 'Describe the first thing you notice when your partner enters a room.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000066', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a compliment you would not say in front of anyone else.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000067', '1f5e0000-0000-4000-8000-100000000005', 'Invent a secret phrase that means you want your partner.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000068', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner a message designed to make them blush.', 2, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000069', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner what you would do if you were alone together.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000070', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner three words designed to distract them.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000071', '1f5e0000-0000-4000-8000-100000000005', 'Record a voice note telling your partner what you want later.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000072', '1f5e0000-0000-4000-8000-100000000005', 'Send a close-up photo and make your partner guess what they are looking at.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000073', '1f5e0000-0000-4000-8000-100000000005', 'Tell your partner which item of their clothing you most want to remove.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000074', '1f5e0000-0000-4000-8000-100000000005', 'Send your partner one instruction to follow when you next meet.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
('1f5e0000-0000-4000-8000-000000000075', '1f5e0000-0000-4000-8000-100000000005', 'Describe one fantasy in a single sentence without using vague words.', 3, 'unreviewed', 'Requires line-by-line universal catalogue review'),
-- Long-Distance Heat
('1f5e0000-0000-4000-8000-000000000076', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo showing exactly what you are doing.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000077', '1f5e0000-0000-4000-8000-100000000006', 'Record a goodnight message your partner will want to replay.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000078', '1f5e0000-0000-4000-8000-100000000006', 'Plan a video-call date and send your partner a dress code.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000079', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo of the empty space where you wish they were.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000080', '1f5e0000-0000-4000-8000-100000000006', 'Order your partner a surprise they can enjoy during your next call.', 2, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000081', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a risky selfie within the next hour.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000082', '1f5e0000-0000-4000-8000-100000000006', 'Record a voice note telling your partner exactly how much you miss their touch.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000083', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo from bed without adding any explanation.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000084', '1f5e0000-0000-4000-8000-100000000006', 'Describe what you would do if your partner appeared beside you.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000085', '1f5e0000-0000-4000-8000-100000000006', 'Give your partner a private fashion show over video.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000086', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a sequence of photos that gradually reveal more.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000087', '1f5e0000-0000-4000-8000-100000000006', 'Record yourself whispering what you want your partner to do when you reunite.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000088', '1f5e0000-0000-4000-8000-100000000006', 'Let your partner choose what you wear during your next private call.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000089', '1f5e0000-0000-4000-8000-100000000006', 'Send your partner a photo they can use as their private wallpaper.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000090', '1f5e0000-0000-4000-8000-100000000006', 'End your next call with a promise your partner can hold you to.', 3, 'unreviewed', 'Private mature editorial inventory'),
-- Risky Photos & Audio
('1f5e0000-0000-4000-8000-000000000091', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo captioned "Only for you."', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000092', '1f5e0000-0000-4000-8000-100000000007', 'Take a silhouette photo that leaves your partner guessing.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000093', '1f5e0000-0000-4000-8000-100000000007', 'Record a voice note describing what you are wearing.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000094', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo that reveals just enough to make them curious.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000095', '1f5e0000-0000-4000-8000-100000000007', 'Take a photo from your partner''s favourite angle.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000096', '1f5e0000-0000-4000-8000-100000000007', 'Record a risky audio clip for your partner within the next hour.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000097', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a photo showing what you plan to wear underneath your clothes.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000098', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a topless photo they have not seen before.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000099', '1f5e0000-0000-4000-8000-100000000007', 'Record yourself describing exactly what you want your partner to do to you.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000100', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a nude using lighting or shadows creatively.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000101', '1f5e0000-0000-4000-8000-100000000007', 'Record the sound you make when your partner touches you properly.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000102', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner a short striptease video.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000103', '1f5e0000-0000-4000-8000-100000000007', 'Take a nude photo without showing your face.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000104', '1f5e0000-0000-4000-8000-100000000007', 'Record a video showing your partner how badly you want them.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000105', '1f5e0000-0000-4000-8000-100000000007', 'Send your partner the most explicit photo you are both comfortable keeping.', 4, 'unreviewed', 'Private mature editorial inventory'),
-- Hands On
('1f5e0000-0000-4000-8000-000000000106', '1f5e0000-0000-4000-8000-100000000008', 'Give your partner a ten-minute massage.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000107', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your partner in five places without kissing their lips.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000108', '1f5e0000-0000-4000-8000-100000000008', 'Trace your fingertips over your partner''s body for five minutes.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000109', '1f5e0000-0000-4000-8000-100000000008', 'Sit on your partner''s lap and kiss them slowly.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000110', '1f5e0000-0000-4000-8000-100000000008', 'Blindfold your partner and feed them something they enjoy.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000111', '1f5e0000-0000-4000-8000-100000000008', 'Let your partner choose where you kiss them.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000112', '1f5e0000-0000-4000-8000-100000000008', 'Tease your partner for ten minutes without touching beneath their clothes.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000113', '1f5e0000-0000-4000-8000-100000000008', 'Give your partner a massage and stop exactly when they want more.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000114', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your partner''s neck until they ask you to stop.', 3, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000115', '1f5e0000-0000-4000-8000-100000000008', 'Remove one item of your partner''s clothing using only one hand.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000116', '1f5e0000-0000-4000-8000-100000000008', 'Guide your partner''s hand to where you want it.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000117', '1f5e0000-0000-4000-8000-100000000008', 'Undress your partner as slowly as possible.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000118', '1f5e0000-0000-4000-8000-100000000008', 'Join your partner in the shower.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000119', '1f5e0000-0000-4000-8000-100000000008', 'Kiss your way down your partner''s body and stop just before reaching their most sensitive spot.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000120', '1f5e0000-0000-4000-8000-100000000008', 'Pleasure your partner for ten minutes without letting them return the favour.', 4, 'unreviewed', 'Private mature editorial inventory'),
-- After Dark
('1f5e0000-0000-4000-8000-000000000121', '1f5e0000-0000-4000-8000-100000000009', 'Tell your partner exactly what you want, then do it together if they want it too.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000122', '1f5e0000-0000-4000-8000-100000000009', 'Give your partner oral sex.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000123', '1f5e0000-0000-4000-8000-100000000009', 'Have sex in a position chosen by your partner.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000124', '1f5e0000-0000-4000-8000-100000000009', 'Masturbate while your partner watches.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000125', '1f5e0000-0000-4000-8000-100000000009', 'Watch your partner masturbate without touching them.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000126', '1f5e0000-0000-4000-8000-100000000009', 'Use a sex toy on your partner.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000127', '1f5e0000-0000-4000-8000-100000000009', 'Let your partner use a sex toy on you.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000128', '1f5e0000-0000-4000-8000-100000000009', 'Masturbate together while watching each other.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000129', '1f5e0000-0000-4000-8000-100000000009', 'Blindfold your partner and pleasure them.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000130', '1f5e0000-0000-4000-8000-100000000009', 'Have sex without completely removing your clothes.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000131', '1f5e0000-0000-4000-8000-100000000009', 'Take your partner somewhere private for a quickie.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000132', '1f5e0000-0000-4000-8000-100000000009', 'Act out a fantasy your partner has previously shared with you.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000133', '1f5e0000-0000-4000-8000-100000000009', 'Tell your partner exactly how to touch you while they do it.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000134', '1f5e0000-0000-4000-8000-100000000009', 'Spend twenty minutes pleasuring each other without penetration.', 4, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000135', '1f5e0000-0000-4000-8000-100000000009', 'Make your partner orgasm without using your hands.', 4, 'unreviewed', 'Private mature editorial inventory'),
-- Power Play
('1f5e0000-0000-4000-8000-000000000136', '1f5e0000-0000-4000-8000-100000000010', 'Give your partner three rules to follow until the dare expires.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000137', '1f5e0000-0000-4000-8000-100000000010', 'Kneel in front of your partner and wait for their first instruction.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000138', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner restrain your wrists using an agreed quick-release restraint.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000139', '1f5e0000-0000-4000-8000-100000000010', 'Wear a blindfold and follow three instructions from your partner.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000140', '1f5e0000-0000-4000-8000-100000000010', 'Give your partner an agreed number of strikes using an agreed implement and intensity.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000141', '1f5e0000-0000-4000-8000-100000000010', 'Act as your partner''s submissive for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000142', '1f5e0000-0000-4000-8000-100000000010', 'Take control of your partner for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000143', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner choose your outfit, position, and posture.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000144', '1f5e0000-0000-4000-8000-100000000010', 'Send your partner a voice note promising to obey one agreed instruction.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000145', '1f5e0000-0000-4000-8000-100000000010', 'Edge your partner and stop before they orgasm.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000146', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner decide when you are allowed to orgasm.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000147', '1f5e0000-0000-4000-8000-100000000010', 'Wear a collar or agreed symbol of submission for your partner.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000148', '1f5e0000-0000-4000-8000-100000000010', 'Serve your partner without speaking for thirty minutes.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000149', '1f5e0000-0000-4000-8000-100000000010', 'Let your partner control a remote toy while you remain somewhere private.', 5, 'unreviewed', 'Private mature editorial inventory'),
('1f5e0000-0000-4000-8000-000000000150', '1f5e0000-0000-4000-8000-100000000010', 'Finish the dare by giving your partner the aftercare they ask for.', 5, 'unreviewed', 'Private mature editorial inventory')
), upsert AS (
INSERT INTO dares (
id, pack_id, text, intensity, content_status, content_review_reason,
content_reviewed_at
)
SELECT id::uuid, pack_id::uuid, text, intensity,
content_status::content_review_status, content_review_reason,
CASE WHEN content_status = 'allowed' THEN '2026-09-05T16:00:00Z'::timestamptz END
FROM seed
ON CONFLICT (id) DO UPDATE SET id = dares.id
WHERE (dares.pack_id, dares.text, dares.intensity, dares.suggested_duration_hours,
dares.content_status, dares.content_review_reason, dares.content_reviewed_at,
dares.content_reviewed_by)
IS NOT DISTINCT FROM
(EXCLUDED.pack_id, EXCLUDED.text, EXCLUDED.intensity,
EXCLUDED.suggested_duration_hours, EXCLUDED.content_status,
EXCLUDED.content_review_reason, EXCLUDED.content_reviewed_at,
EXCLUDED.content_reviewed_by)
RETURNING id
)
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
--> statement-breakpoint
-- A migration has no authenticated human reviewer. Record the system seed as the
-- actor-less, timestamped review decision the schema explicitly permits rather than
-- fabricating a profile UUID. The entries remain durable audit provenance.
WITH seed (id, entity_type, entity_id, previous_status, new_status, reason, created_at) AS (
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
'dare_packs'::content_entity_type, id, 'unreviewed'::content_review_status,
'allowed'::content_review_status, content_review_reason,
'2026-09-05T16:00:00Z'::timestamptz
FROM dare_packs
WHERE id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid
AND '1f5e0000-0000-4000-8000-100000000004'::uuid
UNION ALL
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
'dares'::content_entity_type, id, 'unreviewed'::content_review_status,
'allowed'::content_review_status, content_review_reason,
'2026-09-05T16:00:00Z'::timestamptz
FROM dares
WHERE id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid
AND '1f5e0000-0000-4000-8000-000000000060'::uuid
), upsert AS (
INSERT INTO content_reviews (
id, entity_type, entity_id, previous_status, new_status, reason, changed_by, created_at
)
SELECT id::uuid, entity_type::content_entity_type, entity_id::uuid,
previous_status::content_review_status, new_status::content_review_status,
reason, null::uuid, created_at::timestamptz
FROM seed
ON CONFLICT (id) DO UPDATE SET id = content_reviews.id
WHERE (content_reviews.entity_type, content_reviews.entity_id,
content_reviews.previous_status, content_reviews.new_status,
content_reviews.reason, content_reviews.changed_by,
content_reviews.created_at)
IS NOT DISTINCT FROM
(EXCLUDED.entity_type, EXCLUDED.entity_id, EXCLUDED.previous_status,
EXCLUDED.new_status, EXCLUDED.reason, EXCLUDED.changed_by,
EXCLUDED.created_at)
RETURNING id
)
SELECT CASE WHEN (SELECT count(*) FROM upsert) = (SELECT count(*) FROM seed)
THEN 1 ELSE (SELECT 1 / (count(*) - count(*)) FROM upsert) END;
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM content_reviews r
WHERE (r.entity_type = 'dare_packs' AND r.entity_id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid AND '1f5e0000-0000-4000-8000-100000000004'::uuid)
OR (r.entity_type = 'dares' AND r.entity_id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid AND '1f5e0000-0000-4000-8000-000000000060'::uuid)
GROUP BY r.entity_type, r.entity_id
HAVING count(*) <> 1
) THEN
RAISE EXCEPTION 'catalogue seed review provenance is not one-to-one';
END IF;
IF EXISTS (
WITH expected (id, entity_type, entity_id, reason) AS (
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
'dare_packs'::content_entity_type, id, content_review_reason
FROM dare_packs
WHERE id BETWEEN '1f5e0000-0000-4000-8000-100000000001'::uuid
AND '1f5e0000-0000-4000-8000-100000000004'::uuid
UNION ALL
SELECT ('2f5e0000-0000-4000-8000-' || right(id::text, 12))::uuid,
'dares'::content_entity_type, id, content_review_reason
FROM dares
WHERE id BETWEEN '1f5e0000-0000-4000-8000-000000000001'::uuid
AND '1f5e0000-0000-4000-8000-000000000060'::uuid
)
SELECT 1
FROM expected e
LEFT JOIN content_reviews r
ON r.entity_type = e.entity_type AND r.entity_id = e.entity_id
WHERE (r.id, r.previous_status, r.new_status, r.reason, r.changed_by,
r.created_at)
IS DISTINCT FROM
(e.id, 'unreviewed'::content_review_status,
'allowed'::content_review_status, e.reason, null::uuid,
'2026-09-05T16:00:00Z'::timestamptz)
) THEN
RAISE EXCEPTION 'catalogue seed review provenance does not match the system seed';
END IF;
END $$;
+8 -1
View File
@@ -162,6 +162,13 @@
"when": 1788168150000, "when": 1788168150000,
"tag": "0022_solo_sealed_answers", "tag": "0022_solo_sealed_answers",
"breakpoints": true "breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1788624000000,
"tag": "0023_dare_catalogue_150",
"breakpoints": true
} }
] ]
} }
+127
View File
@@ -1,5 +1,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises'; import { readdir, readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg'; import { Pool } from 'pg';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js'; import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js';
@@ -82,6 +85,130 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' }); await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' });
}); });
it('seeds the 150-item editorial catalogue with fail-closed review states', async () => {
const seeded = await pool.query<{
name: string; is_premium: boolean; is_public: boolean; is_explicit: boolean;
content_status: string; min_intensity: number; max_intensity: number; avg_intensity: string; dare_count: string;
}>(`
select dp.name, dp.is_premium, dp.is_public, dp.is_explicit, dp.content_status,
dp.min_intensity, dp.max_intensity, dp.avg_intensity::text,
count(d.id)::text as dare_count
from dare_packs dp
join dares d on d.pack_id = dp.id
where dp.id between '1f5e0000-0000-4000-8000-100000000001'::uuid
and '1f5e0000-0000-4000-8000-100000000010'::uuid
group by dp.id
order by dp.sort_order
`);
expect(seeded.rows).toEqual([
{ name: 'Little Things', is_premium: false, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 1, avg_intensity: '1.00', dare_count: '15' },
{ name: 'Make Me Laugh', is_premium: false, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 2, avg_intensity: '1.33', dare_count: '15' },
{ name: 'Sweet & Romantic', is_premium: false, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 2, avg_intensity: '1.80', dare_count: '15' },
{ name: 'Surprise Me', is_premium: true, is_public: true, is_explicit: false, content_status: 'allowed', min_intensity: 1, max_intensity: 2, avg_intensity: '1.80', dare_count: '15' },
{ name: 'Flirty Messages', is_premium: true, is_public: false, is_explicit: false, content_status: 'unreviewed', min_intensity: 2, max_intensity: 3, avg_intensity: '2.47', dare_count: '15' },
{ name: 'Long-Distance Heat', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 2, max_intensity: 3, avg_intensity: '2.67', dare_count: '15' },
{ name: 'Risky Photos & Audio', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 3, max_intensity: 4, avg_intensity: '3.53', dare_count: '15' },
{ name: 'Hands On', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 3, max_intensity: 4, avg_intensity: '3.40', dare_count: '15' },
{ name: 'After Dark', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 4, max_intensity: 4, avg_intensity: '4.00', dare_count: '15' },
{ name: 'Power Play', is_premium: true, is_public: false, is_explicit: true, content_status: 'unreviewed', min_intensity: 5, max_intensity: 5, avg_intensity: '5.00', dare_count: '15' },
]);
const distribution = await pool.query<{
total: string; free: string; premium: string; allowed: string; unreviewed: string; duplicate_texts: string; stat_mismatches: string;
}>(`
select count(*)::text as total,
count(*) filter (where not dp.is_premium)::text as free,
count(*) filter (where dp.is_premium)::text as premium,
count(*) filter (where d.content_status = 'allowed')::text as allowed,
count(*) filter (where d.content_status = 'unreviewed')::text as unreviewed,
(select count(*)::text from (
select text from dares
where id between '1f5e0000-0000-4000-8000-000000000001'::uuid
and '1f5e0000-0000-4000-8000-000000000150'::uuid
group by text having count(*) > 1
) duplicates) as duplicate_texts,
(select count(*)::text from (
select dp.id
from dare_packs dp
join dares expected on expected.pack_id = dp.id
where dp.id between '1f5e0000-0000-4000-8000-100000000001'::uuid
and '1f5e0000-0000-4000-8000-100000000010'::uuid
group by dp.id
having dp.min_intensity is distinct from min(expected.intensity)
or dp.max_intensity is distinct from max(expected.intensity)
or dp.avg_intensity is distinct from round(avg(expected.intensity)::numeric, 2)
) mismatches) as stat_mismatches
from dares d
join dare_packs dp on dp.id = d.pack_id
where d.id between '1f5e0000-0000-4000-8000-000000000001'::uuid
and '1f5e0000-0000-4000-8000-000000000150'::uuid
`);
expect(distribution.rows[0]).toEqual({ total: '150', free: '45', premium: '105', allowed: '60', unreviewed: '90', duplicate_texts: '0', stat_mismatches: '0' });
const provenance = await pool.query<{ reviewed_rows: string; review_records: string; attributed_records: string }>(`
select
(select count(*)::text from (
select content_reviewed_at
from dare_packs
where id between '1f5e0000-0000-4000-8000-100000000001'::uuid
and '1f5e0000-0000-4000-8000-100000000004'::uuid
union all
select content_reviewed_at
from dares
where id between '1f5e0000-0000-4000-8000-000000000001'::uuid
and '1f5e0000-0000-4000-8000-000000000060'::uuid
) reviewed where content_reviewed_at = '2026-09-05T16:00:00Z'::timestamptz)::text as reviewed_rows,
(select count(*)::text from content_reviews
where (entity_type = 'dare_packs' and entity_id between '1f5e0000-0000-4000-8000-100000000001'::uuid and '1f5e0000-0000-4000-8000-100000000004'::uuid)
or (entity_type = 'dares' and entity_id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000060'::uuid)) as review_records,
(select count(*)::text from content_reviews
where ((entity_type = 'dare_packs' and entity_id between '1f5e0000-0000-4000-8000-100000000001'::uuid and '1f5e0000-0000-4000-8000-100000000004'::uuid)
or (entity_type = 'dares' and entity_id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000060'::uuid))
and changed_by is not null) as attributed_records
`);
expect(provenance.rows[0]).toEqual({ reviewed_rows: '64', review_records: '64', attributed_records: '0' });
});
it('is discovered by Drizzle and fails closed on a conflicting catalogue ID', async () => {
const migrationSchema = `dare_migrator_${randomUUID().replaceAll('-', '')}`;
await admin.query(`create schema "${migrationSchema}"`);
const migrationUrl = new URL(url!);
migrationUrl.searchParams.set('options', `-c search_path=${migrationSchema}`);
const migrationPool = new Pool({ connectionString: migrationUrl.toString() });
const dir = new URL('../drizzle/', import.meta.url);
try {
await migrate(drizzle(migrationPool), { migrationsFolder: fileURLToPath(dir) });
const discovered = await migrationPool.query<{ count: string }>(
"select count(*)::text as count from dares where id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000150'::uuid",
);
expect(discovered.rows[0]?.count).toBe('150');
const seed = await readFile(new URL('0023_dare_catalogue_150.sql', dir), 'utf8');
const applySeed = async () => {
for (const statement of seed.split('--> statement-breakpoint')) {
if (statement.trim()) await migrationPool.query(statement);
}
};
await applySeed();
await migrationPool.query(
"update content_reviews set reason = 'Conflicting review' where id = '2f5e0000-0000-4000-8000-000000000001'::uuid",
);
await expect(applySeed()).rejects.toThrow(/division by zero|review provenance/);
await migrationPool.query(
"update content_reviews set reason = 'Reviewed with Little Things pack' where id = '2f5e0000-0000-4000-8000-000000000001'::uuid",
);
await migrationPool.query(
"update dares set text = 'Conflicting dare' where id = '1f5e0000-0000-4000-8000-000000000001'::uuid",
);
await expect(applySeed()).rejects.toThrow(/division by zero/);
} finally {
await migrationPool.end();
await admin.query(`drop schema if exists "${migrationSchema}" cascade`);
}
});
it('gates premium packs and custom dares, and unlocks when either partner pays', async () => { it('gates premium packs and custom dares, and unlocks when either partner pays', async () => {
await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' }); await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' });
await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' }); await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' });
+24
View File
@@ -0,0 +1,24 @@
import { readdir, readFile } from 'node:fs/promises';
import { describe, expect, it } from 'vitest';
interface JournalEntry {
idx: number;
tag: string;
}
describe('Drizzle migration journal', () => {
it('lists every ordered SQL migration exactly once', async () => {
const migrationsDirectory = new URL('../drizzle/', import.meta.url);
const journal = JSON.parse(await readFile(new URL('meta/_journal.json', migrationsDirectory), 'utf8')) as {
entries: JournalEntry[];
};
const migrationFiles = (await readdir(migrationsDirectory))
.filter((name) => name.endsWith('.sql'))
.sort();
const journalFiles = journal.entries.map((entry) => `${entry.tag}.sql`);
expect(journal.entries.map((entry) => entry.idx)).toEqual([...journal.entries.keys()]);
expect(new Set(journalFiles).size).toBe(journalFiles.length);
expect(journalFiles).toEqual(migrationFiles);
});
});
+15
View File
@@ -2,6 +2,21 @@
module.exports = { module.exports = {
extends: 'expo', extends: 'expo',
ignorePatterns: ['/dist/*'], ignorePatterns: ['/dist/*'],
rules: {
// eslint-config-expo (SDK 56) pulls eslint-plugin-react-hooks v7, whose
// recommended config adds React Compiler era rules as errors. Existing
// patterns this codebase relies on deliberately (the "latest value" ref
// pattern, effect-driven setState, dynamically created inline
// components, and manual memoization) trip these new rules throughout
// the app. Adopting the stricter rules is a deliberate follow-up
// refactor, not an SDK upgrade side effect, so they are downgraded to
// warnings here.
'react-hooks/refs': 'warn',
'react-hooks/set-state-in-effect': 'warn',
'react-hooks/immutability': 'warn',
'react-hooks/preserve-manual-memoization': 'warn',
'react-hooks/static-components': 'warn',
},
overrides: [ overrides: [
{ {
files: ['plugins/**/*.js'], files: ['plugins/**/*.js'],
+1
View File
@@ -11,5 +11,6 @@ expo-env.d.ts
*.keystore *.keystore
# Fastlane # Fastlane
fastlane-key.json fastlane-key.json
fastlane/.env
fastlane/report.xml fastlane/report.xml
fastlane/README.md fastlane/README.md
+2
View File
@@ -13,3 +13,5 @@ native iOS/Android targets.
- Add stable accessibility labels or `testID` values for critical interactions. - Add stable accessibility labels or `testID` values for critical interactions.
- Verify focused Jest tests, typecheck, lint, then native/Expo behavior as risk - Verify focused Jest tests, typecheck, lint, then native/Expo behavior as risk
requires. UI work needs observable simulator evidence. requires. UI work needs observable simulator evidence.
- Before and after any `expo prebuild`, follow the "After native regeneration"
checklist in `docs/releasing.md`.
+3
View File
@@ -12,5 +12,8 @@ local.properties
*.hprof *.hprof
.cxx/ .cxx/
# generated inline modules
app/src/main/java/inline/
# Bundle artifacts # Bundle artifacts
*.jsbundle *.jsbundle
+6 -55
View File
@@ -11,7 +11,7 @@ def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
react { react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" hermesCommand = new File(["node", "--print", "require.resolve('hermes-compiler/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean() enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
@@ -64,7 +64,7 @@ react {
} }
/** /**
* Set this to true in release builds to optimize the app using R8. * Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/ */
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean() def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
@@ -92,8 +92,9 @@ android {
applicationId 'com.sauci.app' applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 49 versionCode 51
versionName "1.0.7" versionName "1.0.8"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
} }
signingConfigs { signingConfigs {
@@ -181,54 +182,4 @@ dependencies {
} }
apply plugin: 'com.google.gms.google-services' apply plugin: 'com.google.gms.google-services'
apply plugin: 'com.google.firebase.crashlytics' apply plugin: 'com.google.firebase.crashlytics'
// Reanimated 4.1.7 imports Worklets from its pre-AGP-8 CMake output path,
// while AGP 8.14 writes release outputs below intermediates/cxx/RelWithDebInfo.
// Keep the checked-in Android project compatible with the supported SDK 54
// dependency set by materializing that one legacy input after Worklets builds.
def workletsReleaseAbis = ["armeabi-v7a", "arm64-v8a", "x86", "x86_64"]
def syncWorkletsReleaseCmakeInputs = tasks.register("syncWorkletsReleaseCmakeInputs") {
dependsOn(":react-native-worklets:externalNativeBuildRelease")
doLast {
def workletsIntermediates = file("${rootDir}/../../../node_modules/react-native-worklets/android/build/intermediates")
workletsReleaseAbis.each { abi ->
def libraries = fileTree(workletsIntermediates) {
include "cxx/RelWithDebInfo/**/obj/${abi}/libworklets.so"
}.files
if (libraries.size() != 1) {
throw new GradleException("Expected exactly one Worklets release library for ${abi}, found ${libraries.size()}")
}
def source = libraries.iterator().next()
def destination = file("${workletsIntermediates}/cmake/release/obj/${abi}/libworklets.so")
destination.parentFile.mkdirs()
java.nio.file.Files.deleteIfExists(destination.toPath())
source.withInputStream { input ->
destination.withOutputStream { output -> output << input }
}
if (destination.length() != source.length()) {
throw new GradleException("Failed to copy Worklets release library for ${abi}")
}
}
}
}
gradle.projectsEvaluated {
def reanimatedProject = findProject(":react-native-reanimated")
reanimatedProject?.tasks?.configureEach { task ->
if (task.name == "externalNativeBuildRelease" || task.name == "prefabReleasePackage") {
task.dependsOn(syncWorkletsReleaseCmakeInputs)
}
}
tasks.configureEach { task ->
if (task.name.startsWith("configureCMakeRelWithDebInfo")) {
task.dependsOn(
":react-native-worklets:prefabReleasePackage",
":react-native-reanimated:prefabReleasePackage",
":shopify_react-native-skia:prefabReleasePackage",
)
}
}
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>
@@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" tools:node="remove"/>
@@ -12,7 +12,7 @@
<uses-permission android:name="android.permission.USE_BIOMETRIC"/> <uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.USE_FINGERPRINT"/> <uses-permission android:name="android.permission.USE_FINGERPRINT"/>
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
<uses-permission android:name="com.android.vending.BILLING"/> <uses-permission android:name="com.android.vending.BILLING"/>
<queries> <queries>
<package android:name="com.instagram.android"/> <package android:name="com.instagram.android"/>
@@ -22,7 +22,7 @@
<data android:scheme="https"/> <data android:scheme="https"/>
</intent> </intent>
</queries> </queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:requestLegacyExternalStorage="true" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules" android:enableOnBackInvokedCallback="false"> <application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:requestLegacyExternalStorage="true" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
<meta-data android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/notification_icon_color"/> <meta-data android:name="com.google.firebase.messaging.default_notification_color" android:resource="@color/notification_icon_color"/>
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/> <meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/>
<meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/> <meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
@@ -30,13 +30,14 @@
<meta-data android:name="expo.modules.updates.CODE_SIGNING_CERTIFICATE" android:value="-----BEGIN CERTIFICATE-----&#xA;MIICzTCCAbWgAwIBAgIJAPrPjxnUOGisMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV&#xA;BAMTBVNhdWNpMB4XDTI2MDgzMDE4MTAzMFoXDTM2MDgzMDE4MTAzMFowEDEOMAwG&#xA;A1UEAxMFU2F1Y2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWiUac&#xA;8i9bIbNZjv8iunVr6kqlgb9vea98JHz/A83biyszg0+GpmLYsr4zV2F7A6RitiIx&#xA;SOML+wVedyD0F2quWccBGXinvMAds0TxgIOjQfP+8+VJ/duDjveHvn+OFW4s7XxW&#xA;SY7TK1ufDcPWo/f5XzfqtBveug49zv3/F3MwfmMUbEY/zvH7nayPGJNuSyf3wISn&#xA;JIt0BsHNhWykRXCzjaF2fy3nAebOyezbtbapCjUlxPENVtBCzBQ7C9kaQXCBw5mV&#xA;hxhJlOxHWXpHJgDLHpev8A/CWQRh67tsevjKi6R6SDlf0R0xSbDJTElx8goWOOQW&#xA;VO9aDqI+oH0KpWfnAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E&#xA;DDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAyUc20bIRCxoBTJg84mPl&#xA;2nFDWSaAfzgo0K0b6x2T1suuq4LAOUgzBqJISrQAALd0besK1qUZSAMhjQu1wPZT&#xA;MHuWRMxQHfOkyHMYUfOoPMS4bE+4K0WjzfoVt3y1A+oxhcIAQUUGyNgNmebfEN07&#xA;JlX0B8UI8aIMjyfpsIDgZx1piHKwittGy/V4MPOLbmT2BBwS9BELcmWE4CwWbfeo&#xA;JM4lXY0hk3M/GTstRYaUKiwBWEU+z1PdAmWAgrtxpVnXN+oaToWh0WbVkbF1K6r5&#xA;3oBGFTL7kT+Wogg9GK04XoAREw0jZJ/k77oJx68lGVBNiB+Ue2KqX0H/0cf2CslY&#xA;Ug==&#xA;-----END CERTIFICATE-----&#xA;"/> <meta-data android:name="expo.modules.updates.CODE_SIGNING_CERTIFICATE" android:value="-----BEGIN CERTIFICATE-----&#xA;MIICzTCCAbWgAwIBAgIJAPrPjxnUOGisMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV&#xA;BAMTBVNhdWNpMB4XDTI2MDgzMDE4MTAzMFoXDTM2MDgzMDE4MTAzMFowEDEOMAwG&#xA;A1UEAxMFU2F1Y2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWiUac&#xA;8i9bIbNZjv8iunVr6kqlgb9vea98JHz/A83biyszg0+GpmLYsr4zV2F7A6RitiIx&#xA;SOML+wVedyD0F2quWccBGXinvMAds0TxgIOjQfP+8+VJ/duDjveHvn+OFW4s7XxW&#xA;SY7TK1ufDcPWo/f5XzfqtBveug49zv3/F3MwfmMUbEY/zvH7nayPGJNuSyf3wISn&#xA;JIt0BsHNhWykRXCzjaF2fy3nAebOyezbtbapCjUlxPENVtBCzBQ7C9kaQXCBw5mV&#xA;hxhJlOxHWXpHJgDLHpev8A/CWQRh67tsevjKi6R6SDlf0R0xSbDJTElx8goWOOQW&#xA;VO9aDqI+oH0KpWfnAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E&#xA;DDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAyUc20bIRCxoBTJg84mPl&#xA;2nFDWSaAfzgo0K0b6x2T1suuq4LAOUgzBqJISrQAALd0besK1qUZSAMhjQu1wPZT&#xA;MHuWRMxQHfOkyHMYUfOoPMS4bE+4K0WjzfoVt3y1A+oxhcIAQUUGyNgNmebfEN07&#xA;JlX0B8UI8aIMjyfpsIDgZx1piHKwittGy/V4MPOLbmT2BBwS9BELcmWE4CwWbfeo&#xA;JM4lXY0hk3M/GTstRYaUKiwBWEU+z1PdAmWAgrtxpVnXN+oaToWh0WbVkbF1K6r5&#xA;3oBGFTL7kT+Wogg9GK04XoAREw0jZJ/k77oJx68lGVBNiB+Ue2KqX0H/0cf2CslY&#xA;Ug==&#xA;-----END CERTIFICATE-----&#xA;"/>
<meta-data android:name="expo.modules.updates.CODE_SIGNING_METADATA" android:value="{&quot;keyid&quot;:&quot;main&quot;,&quot;alg&quot;:&quot;rsa-v1_5-sha256&quot;}"/> <meta-data android:name="expo.modules.updates.CODE_SIGNING_METADATA" android:value="{&quot;keyid&quot;:&quot;main&quot;,&quot;alg&quot;:&quot;rsa-v1_5-sha256&quot;}"/>
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/> <meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.ENABLE_BSDIFF_PATCH_SUPPORT" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/> <meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/> <meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="5000"/> <meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="5000"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://ota.apps.jbrunton.co.uk/manifest"/> <meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://ota.apps.jbrunton.co.uk/manifest"/>
<meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value="{&quot;expo-channel-name&quot;:&quot;production&quot;,&quot;expo-app-id&quot;:&quot;9a66f3c2-fda7-416f-87e2-6db833b32e9d&quot;,&quot;xprem-branch&quot;:&quot;&quot;}"/> <meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value="{&quot;expo-channel-name&quot;:&quot;production&quot;,&quot;expo-app-id&quot;:&quot;9a66f3c2-fda7-416f-87e2-6db833b32e9d&quot;,&quot;xprem-branch&quot;:&quot;&quot;}"/>
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/> <service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait"> <activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode|smallestScreenSize|assetsPaths" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
@@ -46,7 +47,6 @@
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/> <category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="app.sauci"/> <data android:scheme="app.sauci"/>
<data android:scheme="com.sauci.app"/>
<data android:scheme="exp+sauci"/> <data android:scheme="exp+sauci"/>
</intent-filter> </intent-filter>
<intent-filter android:autoVerify="true" data-generated="true"> <intent-filter android:autoVerify="true" data-generated="true">
@@ -56,7 +56,7 @@
<category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.DEFAULT"/>
</intent-filter> </intent-filter>
</activity> </activity>
<receiver android:name=".widget.LiveDraw" android:exported="true" android:label="Live Draw"> <receiver android:name=".widget.LiveDraw" android:exported="false" android:label="Live Draw">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.sauci.app.WIDGET_CLICK"/> <action android:name="com.sauci.app.WIDGET_CLICK"/>
@@ -1,4 +1,5 @@
package com.sauci.app package com.sauci.app
import expo.modules.splashscreen.SplashScreenManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -15,7 +16,10 @@ class MainActivity : ReactActivity() {
// Set the theme to AppTheme BEFORE onCreate to support // Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar. // coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen. // This is required for expo-splash-screen.
setTheme(R.style.AppTheme); // setTheme(R.style.AppTheme);
// @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
SplashScreenManager.registerOnActivity(this)
// @generated end expo-splashscreen
super.onCreate(null) super.onCreate(null)
} }
@@ -5,40 +5,27 @@ import android.content.res.Configuration
import com.facebook.react.PackageList import com.facebook.react.PackageList
import com.facebook.react.ReactApplication import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeHost import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactPackage import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
import com.facebook.react.common.ReleaseLevel import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper import expo.modules.ExpoReactHostFactory
class MainApplication : Application(), ReactApplication { class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( override val reactHost: ReactHost by lazy {
this, ExpoReactHostFactory.getDefaultReactHost(
object : DefaultReactNativeHost(this) { context = applicationContext,
override fun getPackages(): List<ReactPackage> { packageList =
val packages = PackageList(this).packages PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example: // Packages that cannot be autolinked yet can be added manually here, for example:
// packages.add(new MyReactNativePackage()); // add(MyReactNativePackage())
return packages }
} )
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
}
)
override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -1,84 +1,6 @@
package com.sauci.app.widget; package com.sauci.app.widget;
import android.appwidget.AppWidgetManager; import com.reactnativeandroidwidget.RNWidgetProvider;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.util.Base64;
import android.util.TypedValue;
import android.widget.RemoteViews;
import com.sauci.app.R; public class LiveDraw extends RNWidgetProvider {
public class LiveDraw extends AppWidgetProvider {
private static final String PREFS_NAME_SUFFIX = ".widgetdata";
private static final String KEY_IMAGE = "LiveDrawImage";
private static final int BG_COLOR = 0xFF1A1A2E;
private static final int CORNER_RADIUS_DP = 16;
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
for (int widgetId : appWidgetIds) {
updateWidget(context, appWidgetManager, widgetId);
}
}
private void updateWidget(Context context, AppWidgetManager appWidgetManager, int widgetId) {
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_live_draw);
String base64 = getStoredImage(context);
if (base64 != null && !base64.isEmpty()) {
try {
byte[] bytes = Base64.decode(base64, Base64.DEFAULT);
Bitmap decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (decoded != null) {
Bitmap rounded = addRoundedBackground(context, decoded);
views.setImageViewBitmap(R.id.widget_image, rounded);
decoded.recycle();
}
} catch (Exception e) {
setPlaceholder(views);
}
} else {
setPlaceholder(views);
}
appWidgetManager.updateAppWidget(widgetId, views);
}
private String getStoredImage(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
context.getPackageName() + PREFS_NAME_SUFFIX, Context.MODE_PRIVATE);
return prefs.getString(KEY_IMAGE, null);
}
private void setPlaceholder(RemoteViews views) {
views.setImageViewResource(R.id.widget_image, R.drawable.widget_placeholder);
}
private Bitmap addRoundedBackground(Context context, Bitmap drawing) {
float radius = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, CORNER_RADIUS_DP,
context.getResources().getDisplayMetrics());
int w = drawing.getWidth();
int h = drawing.getHeight();
Bitmap output = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);
Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
bgPaint.setColor(BG_COLOR);
canvas.drawRoundRect(new RectF(0, 0, w, h), radius, radius, bgPaint);
canvas.drawBitmap(drawing, 0, 0, null);
return output;
}
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -2,6 +2,5 @@
<color name="splashscreen_background">#1a1a2e</color> <color name="splashscreen_background">#1a1a2e</color>
<color name="iconBackground">#1a1a2e</color> <color name="iconBackground">#1a1a2e</color>
<color name="colorPrimary">#023c69</color> <color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#1a1a2e</color>
<color name="notification_icon_color">#e94560</color> <color name="notification_icon_color">#e94560</color>
</resources> </resources>
@@ -1,7 +1,7 @@
<resources> <resources>
<string name="app_name">Sauci</string> <string name="app_name">Sauci</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string> <string name="expo_system_ui_user_interface_style" translatable="false">automatic</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string> <string name="expo_runtime_version">1.0.8</string>
<string name="widget_livedraw_description" translatable="false">See your shared drawing with your partner.</string> <string name="widget_livedraw_description" translatable="false">See your shared drawing with your partner.</string>
<string name="expo_runtime_version">1.0.7</string> <string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
</resources> </resources>
@@ -1,18 +1,14 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar"> <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
<item name="android:textColor">@android:color/black</item>
<item name="android:editTextStyle">@style/ResetEditText</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item> <item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item> <item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#1a1a2e</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style> </style>
<style name="ResetEditText" parent="@android:style/Widget.EditText"> <style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
<item name="android:padding">0dp</item> <item name="windowSplashScreenBackground">@color/splashscreen_background</item>
<item name="android:textColorHint">#c8c8c8</item> <item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
<item name="android:textColor">@android:color/black</item> <item name="postSplashScreenTheme">@style/AppTheme</item>
</style> <item name="android:windowSplashScreenBehavior">icon_preferred</item>
<style name="Theme.App.SplashScreen" parent="AppTheme">
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
</style> </style>
</resources> </resources>
@@ -4,10 +4,18 @@
android:minHeight="110dp" android:minHeight="110dp"
android:targetCellWidth="4" android:targetCellWidth="4"
android:targetCellHeight="2" android:targetCellHeight="2"
android:resizeMode="none" android:resizeMode="none"
android:description="@string/widget_livedraw_description" android:description="@string/widget_livedraw_description"
android:initialLayout="@layout/widget_live_draw"
android:initialLayout="@layout/rn_widget"
android:previewImage="@drawable/livedraw_preview" android:previewImage="@drawable/livedraw_preview"
android:updatePeriodMillis="1800000" android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"> android:widgetCategory="home_screen">
</appwidget-provider> </appwidget-provider>
+18 -18
View File
@@ -1,26 +1,26 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript { buildscript {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.6' classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.6'
classpath 'com.google.gms:google-services:4.4.1' classpath 'com.google.gms:google-services:4.4.4'
classpath('com.android.tools.build:gradle') classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin') classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
} }
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
} }
apply plugin: "expo-root-project" apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject" apply plugin: "com.facebook.react.rootproject"
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
}
}
+10 -14
View File
@@ -10,12 +10,12 @@
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the # AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK # Android operating system, and which are packaged with your app's APK
@@ -41,6 +41,11 @@ newArchEnabled=true
# If set to false, you will be using JSC instead. # If set to false, you will be using JSC instead.
hermesEnabled=true hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Enable GIF support in React Native images (~200 B increase) # Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase) # Enable webp support in React Native images (~85 KB increase)
@@ -55,16 +60,7 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK. # Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false expo.useLegacyPackaging=false
android.targetSdkVersion=36 expo.inlineModules.watchedDirectories=[]
android.extraMavenRepos=[]
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
expo.edgeToEdgeEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
android.compileSdkVersion=36 android.compileSdkVersion=36
android.buildToolsVersion=36.0.0 android.targetSdkVersion=36
android.buildToolsVersion=36.0.0
Binary file not shown.
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+4 -8
View File
@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015-2021 the original authors. # Copyright © 2015 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum
@@ -115,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -173,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java # For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" ) JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -206,15 +203,14 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command: # Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped. # and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line. # treated as '${Hostname}' itself on the command line.
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.
+6 -2
View File
@@ -1,3 +1,8 @@
@REM Copyright (c) Meta Platforms, Inc. and affiliates.
@REM
@REM This source code is licensed under the MIT license found in the
@REM LICENSE file in the root directory of this source tree.
@rem @rem
@rem Copyright 2015 the original author or authors. @rem Copyright 2015 the original author or authors.
@rem @rem
@@ -70,11 +75,10 @@ goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
+2 -2
View File
@@ -6,7 +6,7 @@ pluginManagement {
}.standardOutput.asText.get().trim() }.standardOutput.asText.get().trim()
).getParentFile().absolutePath ).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin) includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File( def expoPluginsPath = new File(
providers.exec { providers.exec {
workingDir(rootDir) workingDir(rootDir)
@@ -29,10 +29,10 @@ extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
} }
} }
expoAutolinking.useExpoModules()
rootProject.name = 'Sauci' rootProject.name = 'Sauci'
expoAutolinking.useExpoModules()
expoAutolinking.useExpoVersionCatalog() expoAutolinking.useExpoVersionCatalog()
include ':app' include ':app'
+1 -1
View File
@@ -81,7 +81,7 @@ module.exports = () => ({
// Keep this explicit because EOAS validates a primitive runtime string before // Keep this explicit because EOAS validates a primitive runtime string before
// upload. The release version script updates it atomically with app.json and // upload. The release version script updates it atomically with app.json and
// the checked-in native runtime values. // the checked-in native runtime values.
runtimeVersion: '1.0.7', runtimeVersion: '1.0.8',
updates: { updates: {
enabled: true, enabled: true,
url: OTA_MANIFEST_URL, url: OTA_MANIFEST_URL,
+19 -10
View File
@@ -2,17 +2,15 @@
"expo": { "expo": {
"name": "Sauci", "name": "Sauci",
"slug": "sauci", "slug": "sauci",
"version": "1.0.7", "version": "1.0.8",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"scheme": "app.sauci", "scheme": "app.sauci",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"newArchEnabled": true, "platforms": [
"splash": { "ios",
"image": "./assets/splash-icon.png", "android"
"resizeMode": "contain", ],
"backgroundColor": "#1a1a2e"
},
"ios": { "ios": {
"supportsTablet": false, "supportsTablet": false,
"bundleIdentifier": "com.sauci.app", "bundleIdentifier": "com.sauci.app",
@@ -27,7 +25,7 @@
"group.com.sauci.app" "group.com.sauci.app"
] ]
}, },
"buildNumber": "46", "buildNumber": "49",
"usesAppleSignIn": true, "usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist", "googleServicesFile": "./GoogleService-Info.plist",
"infoPlist": { "infoPlist": {
@@ -189,7 +187,7 @@
"backgroundColor": "#1a1a2e" "backgroundColor": "#1a1a2e"
}, },
"package": "com.sauci.app", "package": "com.sauci.app",
"versionCode": 49, "versionCode": 51,
"googleServicesFile": "./google-services.json", "googleServicesFile": "./google-services.json",
"permissions": [ "permissions": [
"RECEIVE_BOOT_COMPLETED", "RECEIVE_BOOT_COMPLETED",
@@ -223,6 +221,14 @@
}, },
"plugins": [ "plugins": [
"./plugins/withFixHoisting.js", "./plugins/withFixHoisting.js",
[
"expo-splash-screen",
{
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#1a1a2e"
}
],
"@react-native-firebase/app", "@react-native-firebase/app",
[ [
"expo-build-properties", "expo-build-properties",
@@ -320,7 +326,10 @@
"origin": false "origin": false
}, },
"eas": { "eas": {
"projectId": "807ca0af-e9bd-40e8-a658-f3382072c724" "projectId": "807ca0af-e9bd-40e8-a658-f3382072c724",
"observe": {
"endpointUrl": "https://ota.apps.jbrunton.co.uk/observe/9a66f3c2-fda7-416f-87e2-6db833b32e9d"
}
} }
}, },
"owner": "jjbrunton-apps" "owner": "jjbrunton-apps"
+17 -6
View File
@@ -69,10 +69,12 @@ export default function MatchesScreen() {
checkNudgeCooldown, checkNudgeCooldown,
} = useMatchStore(); } = useMatchStore();
const { user, couple, partner } = useAuthStore(); const { user, couple, partner } = useAuthStore();
const userId = user?.id;
const router = useRouter(); const router = useRouter();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH; const isWideScreen = width > MAX_CONTENT_WIDTH;
const [showTutorial, setShowTutorial] = useState(false); const [showTutorial, setShowTutorial] = useState(false);
const tutorialDismissedRef = useRef(false);
const [nudgeFeedback, setNudgeFeedback] = useState<string | null>(null); const [nudgeFeedback, setNudgeFeedback] = useState<string | null>(null);
const [headerHeight, setHeaderHeight] = useState(130); const [headerHeight, setHeaderHeight] = useState(130);
@@ -104,21 +106,30 @@ export default function MatchesScreen() {
// Check if tutorial should be shown when screen is focused or matches change // Check if tutorial should be shown when screen is focused or matches change
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
let isActive = true;
const checkTutorial = async () => { const checkTutorial = async () => {
if (matches.length > 0) { if (matches.length > 0 && userId) {
const seen = await hasSeenMatchesTutorial(); const seen = await hasSeenMatchesTutorial(userId);
if (!seen) { if (isActive && !tutorialDismissedRef.current && !seen) {
setShowTutorial(true); setShowTutorial(true);
} }
} }
}; };
checkTutorial(); void checkTutorial();
}, [matches.length])
return () => {
isActive = false;
};
}, [matches.length, userId])
); );
const handleTutorialComplete = async () => { const handleTutorialComplete = async () => {
await markMatchesTutorialSeen(); if (!userId) return;
tutorialDismissedRef.current = true;
setShowTutorial(false); setShowTutorial(false);
await markMatchesTutorialSeen(userId);
}; };
// Load whichever view is showing, once. Returning to this tab reuses what is // Load whichever view is showing, once. Returning to this tab reuses what is
+1 -1
View File
@@ -389,7 +389,7 @@ const styles = StyleSheet.create({
zIndex: 100, zIndex: 100,
}, },
navBarBackground: { navBarBackground: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: colors.border, borderBottomColor: colors.border,
backgroundColor: colors.background, backgroundColor: colors.background,
+42 -200
View File
@@ -9,14 +9,12 @@ import {
Platform, Platform,
Keyboard, Keyboard,
ScrollView, ScrollView,
Image,
} from 'react-native'; } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import Animated, { import Animated, {
FadeIn, FadeIn,
FadeInUp,
FadeOut, FadeOut,
SlideInRight, SlideInRight,
SlideOutLeft, SlideOutLeft,
@@ -32,9 +30,9 @@ import { getProfileError } from '../../src/lib/errors';
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications'; import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
import { Events } from '../../src/lib/analytics'; import { Events } from '../../src/lib/analytics';
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen'; import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker'; import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding';
import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding'; import { activeOnboardingStage, initialOnboardingStage, previousOnboardingStage, type OnboardingStage } from '../../src/lib/onboardingStages';
import type { Gender, UsageReason } from '../../src/types'; import type { Gender, UsageReason } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'male', label: 'Male', icon: 'male' }, { value: 'male', label: 'Male', icon: 'male' },
@@ -51,15 +49,17 @@ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' }, { value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
]; ];
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications'; type Stage = OnboardingStage;
export default function OnboardingScreen() { export default function OnboardingScreen() {
const router = useRouter(); const router = useRouter();
// Only subscribe to stable values to prevent re-renders from background fetchUser calls // Only subscribe to stable values to prevent re-renders from background fetchUser calls
const userId = useAuthStore((s) => s.user?.id); const userId = useAuthStore((s) => s.user?.id);
const persistedUserName = useAuthStore((s) => s.user?.name);
const pendingAppleDisplayName = useAuthStore((s) => s.pendingAppleDisplayName);
const fetchUser = useAuthStore((s) => s.fetchUser); const fetchUser = useAuthStore((s) => s.fetchUser);
const initialUser = useRef(useAuthStore.getState().user); const initialUser = useRef(useAuthStore.getState().user);
const [stage, setStage] = useState<Stage>('avatar'); const [stage, setStage] = useState<Stage>(() => initialOnboardingStage(initialUser.current?.name));
const [name, setName] = useState(initialUser.current?.name || ''); const [name, setName] = useState(initialUser.current?.name || '');
const [gender, setGender] = useState<Gender | null>(initialUser.current?.gender || null); const [gender, setGender] = useState<Gender | null>(initialUser.current?.gender || null);
const [usageReason, setUsageReason] = useState<UsageReason | null>(null); const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
@@ -67,38 +67,18 @@ export default function OnboardingScreen() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showPaywall, setShowPaywall] = useState(false); const [showPaywall, setShowPaywall] = useState(false);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const paywallNavigationRef = useRef(false); const paywallNavigationRef = useRef(false);
const { const pendingDisplayName = pendingAppleDisplayName?.userId === userId ? pendingAppleDisplayName?.name : undefined;
avatarUri, const displayName = persistedUserName?.trim() || pendingDisplayName || name;
isUploading: isUploadingAvatar, const hasStoredDisplayName = Boolean(persistedUserName?.trim() || pendingDisplayName);
showPicker: handleAvatarPress, const activeStage = activeOnboardingStage(stage, persistedUserName, pendingDisplayName);
uploadAvatar, const previousStage = previousOnboardingStage(activeStage, hasStoredDisplayName);
} = useAvatarPicker({
userId,
onSelect: () => setError(null),
});
useEffect(() => { useEffect(() => {
Events.onboardingStart(); Events.onboardingStart();
}, []); }, []);
const handleAvatarContinue = () => {
if (!avatarUri) {
setError('Please add a profile photo to continue');
return;
}
setStage('name');
Events.onboardingStageComplete('avatar');
};
const handleAvatarSkip = () => {
setError(null);
setStage('name');
Events.avatarSkipped();
Events.onboardingStageComplete('avatar');
};
const handleNameSubmit = () => { const handleNameSubmit = () => {
if (!name.trim()) { if (!name.trim()) {
setError('Please enter your name'); setError('Please enter your name');
@@ -154,7 +134,7 @@ export default function OnboardingScreen() {
}; };
const handleComplete = async () => { const handleComplete = async () => {
console.log('[Onboarding] handleComplete called, user:', userId, 'name:', name); console.log('[Onboarding] handleComplete called, user:', userId, 'name:', displayName);
if (!userId) { if (!userId) {
console.error('[Onboarding] No user ID available'); console.error('[Onboarding] No user ID available');
setError('Not logged in. Please restart the app.'); setError('Not logged in. Please restart the app.');
@@ -165,19 +145,10 @@ export default function OnboardingScreen() {
setError(null); setError(null);
try { try {
// Upload avatar if one was selected
let avatarUrl: string | null = null;
if (avatarUri) {
avatarUrl = await uploadAvatar();
if (avatarUrl) {
Events.avatarUploaded();
}
}
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"]; const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
const updateData = { const updateData = {
name: name.trim(), name: displayName.trim(),
gender, gender,
usage_reason: usageReason, usage_reason: usageReason,
// Preserve the safest legacy profile values. Catalogue // Preserve the safest legacy profile values. Catalogue
@@ -191,10 +162,6 @@ export default function OnboardingScreen() {
await profileSettingsApi.updateProfile(updateData); await profileSettingsApi.updateProfile(updateData);
if (avatarUrl) {
updatedFields.push("avatar_url");
}
console.log('[Onboarding] Profile updated successfully, fetching user...'); console.log('[Onboarding] Profile updated successfully, fetching user...');
await fetchUser(); await fetchUser();
console.log('[Onboarding] User fetched, navigating to home...'); console.log('[Onboarding] User fetched, navigating to home...');
@@ -231,72 +198,8 @@ export default function OnboardingScreen() {
}; };
const renderStage = () => { const renderStage = () => {
switch (stage) { switch (activeStage) {
case 'avatar': case 'name':
return (
<Animated.View
key="avatar"
entering={FadeInUp.duration(500)}
exiting={SlideOutLeft.duration(300)}
style={styles.stageContainer}
>
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="heart" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.title}>Welcome to Sauci</Text>
<Text style={styles.subtitle}>
Add a profile photo so your partner knows it's you
</Text>
</View>
<GlassCard style={styles.card}>
<View style={styles.avatarSection}>
<Pressable onPress={handleAvatarPress} style={styles.avatarTouchable}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
) : (
<View
style={styles.avatarPlaceholder}
>
<Ionicons name="camera" size={40} color={colors.textSecondary} />
</View>
)}
<View style={styles.avatarBadge}>
<Ionicons name={avatarUri ? "pencil" : "add"} size={16} color={colors.text} />
</View>
</Pressable>
<Text style={styles.avatarHint}>
{avatarUri ? 'Tap to change photo' : 'Tap to add a photo'}
</Text>
</View>
{error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</GlassCard>
<View style={styles.footer}>
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
Continue
</GlassButton>
<Pressable
style={styles.skipButton}
onPress={handleAvatarSkip}
testID="avatar-skip-button"
>
<Text style={styles.skipText}>Skip for now</Text>
</Pressable>
</View>
</Animated.View>
);
case 'name':
return ( return (
<Animated.View <Animated.View
key="name" key="name"
@@ -305,16 +208,12 @@ export default function OnboardingScreen() {
style={styles.stageContainer} style={styles.stageContainer}
> >
<View style={styles.header}> <View style={styles.header}>
{avatarUri ? ( <LinearGradient
<Image source={{ uri: avatarUri }} style={styles.headerAvatar} /> colors={gradients.primary as [string, string]}
) : ( style={styles.iconContainer}
<LinearGradient >
colors={gradients.primary as [string, string]} <Ionicons name="person" size={40} color={colors.text} />
style={styles.iconContainer} </LinearGradient>
>
<Ionicons name="person" size={40} color={colors.text} />
</LinearGradient>
)}
<Text style={styles.title}>What can we call you?</Text> <Text style={styles.title}>What can we call you?</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Nicknames and pet names are welcome too! Nicknames and pet names are welcome too!
@@ -368,7 +267,7 @@ export default function OnboardingScreen() {
style={styles.stageContainer} style={styles.stageContainer}
> >
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.greeting}>Hey, {name.trim()}!</Text> <Text style={styles.greeting}>Hey, {displayName.trim()}!</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
This helps us show you the right questions This helps us show you the right questions
</Text> </Text>
@@ -576,28 +475,24 @@ export default function OnboardingScreen() {
> >
{/* Header with back button and progress dots */} {/* Header with back button and progress dots */}
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}> <Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
{stage !== 'avatar' ? ( {previousStage ? (
<Pressable <Pressable
style={styles.backButton} style={styles.backButton}
onPress={() => { onPress={() => {
setError(null); setError(null);
if (stage === 'notifications') setStage('purpose'); setStage(previousStage);
else if (stage === 'purpose') setStage('gender'); }}
else if (stage === 'gender') setStage('name');
else if (stage === 'name') setStage('avatar');
}}
> >
<Ionicons name="chevron-back" size={24} color={colors.text} /> <Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable> </Pressable>
) : ( ) : (
<View style={styles.backButtonPlaceholder} /> <View style={styles.backButtonPlaceholder} />
)} )}
<View style={styles.progressContainer}> <View style={styles.progressContainer}>
<View style={[styles.progressDot, stage === 'avatar' && styles.progressDotActive]} /> <View style={[styles.progressDot, activeStage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} /> <View style={[styles.progressDot, activeStage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} /> <View style={[styles.progressDot, activeStage === 'purpose' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} /> <View style={[styles.progressDot, activeStage === 'notifications' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
</View> </View>
<View style={styles.backButtonPlaceholder} /> <View style={styles.backButtonPlaceholder} />
</Animated.View> </Animated.View>
@@ -653,18 +548,6 @@ const styles = StyleSheet.create({
backgroundColor: colors.border, backgroundColor: colors.border,
}, },
// ... // ...
avatarPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.border,
borderStyle: 'dashed',
backgroundColor: colors.backgroundLight,
},
// ...
inputContainer: { inputContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -757,47 +640,6 @@ const styles = StyleSheet.create({
marginBottom: spacing.lg, marginBottom: spacing.lg,
...shadows.lg, ...shadows.lg,
}, },
headerAvatar: {
width: 80,
height: 80,
borderRadius: 40,
marginBottom: spacing.lg,
borderWidth: 2,
borderColor: colors.primary,
},
avatarSection: {
alignItems: 'center',
paddingVertical: spacing.lg,
},
avatarTouchable: {
position: 'relative',
},
avatarImage: {
width: 120,
height: 120,
borderRadius: 60,
borderWidth: 3,
borderColor: colors.primary,
},
avatarBadge: {
position: 'absolute',
bottom: 4,
right: 4,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.background,
},
avatarHint: {
...typography.caption1,
color: colors.textSecondary,
marginTop: spacing.md,
},
title: { title: {
...typography.largeTitle, ...typography.largeTitle,
color: colors.text, color: colors.text,
+36 -1
View File
@@ -46,6 +46,11 @@ if (Platform.OS === "android") {
import { authClient } from "../../src/lib/authClient"; import { authClient } from "../../src/lib/authClient";
import { getAuthError } from "../../src/lib/errors"; import { getAuthError } from "../../src/lib/errors";
import { Events } from "../../src/lib/analytics"; import { Events } from "../../src/lib/analytics";
import { apiRequestWithAccessToken } from "../../src/lib/apiClient";
import { formatAppleProfileName } from "../../src/lib/appleProfileName";
import { persistAppleProfileName } from "../../src/lib/appleProfileNamePersistence";
import { profileSettingsApi } from "../../src/lib/profileSettingsApi";
import type { Profile } from "../../src/types";
import { useAuthStore } from "../../src/store"; import { useAuthStore } from "../../src/store";
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui"; import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
import { colors, spacing, radius, typography } from "../../src/theme"; import { colors, spacing, radius, typography } from "../../src/theme";
@@ -290,7 +295,7 @@ export default function LoginScreen() {
console.log("[Apple Sign In] Got credential:", !!credential, "identityToken:", !!credential?.identityToken); console.log("[Apple Sign In] Got credential:", !!credential, "identityToken:", !!credential?.identityToken);
if (credential.identityToken) { if (credential.identityToken) {
const { error } = await authClient.auth.signInWithIdToken({ const { data, error } = await authClient.auth.signInWithIdToken({
provider: "apple", provider: "apple",
token: credential.identityToken, token: credential.identityToken,
}); });
@@ -299,6 +304,36 @@ export default function LoginScreen() {
console.log("[Apple Sign In] Supabase error:", error.message); console.log("[Apple Sign In] Supabase error:", error.message);
Alert.alert("Error", getAuthError(error)); Alert.alert("Error", getAuthError(error));
} else { } else {
const appleProfileName = formatAppleProfileName(credential.fullName);
const userId = data.user?.id;
const accessToken = data.session?.access_token;
if (appleProfileName && userId && accessToken) {
const authStore = useAuthStore.getState();
authStore.setPendingAppleDisplayName({ userId, name: appleProfileName });
try {
await persistAppleProfileName(
{ userId, accessToken },
appleProfileName,
{
getProfile: (token) => apiRequestWithAccessToken<{ profile: Profile }>("/v1/me", token),
updateProfileName: (token, name) => profileSettingsApi.updateProfileWithAccessToken(token, { name }),
},
);
await authStore.fetchUser({
userId,
accessToken,
isAnonymous: !!(data.user as { is_anonymous?: boolean } | null)?.is_anonymous,
});
} catch {
// The account session remains valid. Clear the subject-keyed
// temporary name so onboarding safely falls back to its name step.
console.warn("[Apple Sign In] Could not persist supplied name");
} finally {
useAuthStore.getState().clearPendingAppleDisplayName(userId);
}
} else if (appleProfileName) {
Alert.alert("Error", "Apple sign in did not return an authenticated session. Please try again.");
}
Events.signIn("apple"); Events.signIn("apple");
} }
} else { } else {
+20 -14
View File
@@ -5,6 +5,11 @@ import "../src/polyfills/web";
import { initCrashlytics, setUserContext, clearUserContext } from "../src/lib/crashlytics"; import { initCrashlytics, setUserContext, clearUserContext } from "../src/lib/crashlytics";
initCrashlytics(); initCrashlytics();
// Install the Observe global JS error handler after Crashlytics so it wraps
// (and always calls) whatever handler is already registered.
import { installObserveGlobalErrorHandler, logAppStarted } from "../src/lib/observe";
installObserveGlobalErrorHandler();
// Analytics imports (initialization deferred until app mounts) // Analytics imports (initialization deferred until app mounts)
import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics"; import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics";
@@ -73,6 +78,7 @@ export default function RootLayout() {
analyticsInitialized.current = true; analyticsInitialized.current = true;
initAnalytics(); initAnalytics();
Events.appOpened("cold"); Events.appOpened("cold");
logAppStarted();
} }
}, []); }, []);
@@ -115,15 +121,15 @@ export default function RootLayout() {
// Fetch user on mount // Fetch user on mount
fetchUser(); fetchUser();
// Handle initial URL if app was opened via deep link // Handle initial URL if app was opened via deep link
const handleDeepLink = async (url: string | null) => { const handleDeepLink = async (url: string | null) => {
if (!url) return; if (!url) return;
// Invite links (https://sauci.app/join/{code} or app.sauci://join?code={code}) // Invite links (https://sauci.app/join/{code} or app.sauci://join?code={code})
// are handled separately from the Supabase auth deep link flow below. // are handled separately from the Supabase auth deep link flow below.
const inviteLink = parseInviteLinkCode(url); const inviteLink = parseInviteLinkCode(url);
if (inviteLink) { if (inviteLink) {
Events.inviteLinkOpened(inviteLink.source); Events.inviteLinkOpened(inviteLink.source);
await stashPendingInviteCode(inviteLink.code); await stashPendingInviteCode(inviteLink.code);
const { isAuthenticated, user } = useAuthStore.getState(); const { isAuthenticated, user } = useAuthStore.getState();
@@ -200,15 +206,15 @@ export default function RootLayout() {
} }
}; };
// Get initial URL // Get initial URL
Linking.getInitialURL().then((url) => { Linking.getInitialURL().then((url) => {
handleDeepLink(url); handleDeepLink(url);
}); });
// Listen for new deep links while app is open // Listen for new deep links while app is open
const linkingSubscription = Linking.addEventListener("url", (event) => { const linkingSubscription = Linking.addEventListener("url", (event) => {
handleDeepLink(event.url); handleDeepLink(event.url);
}); });
// Set up push notification response listener (for taps while app is running) // Set up push notification response listener (for taps while app is running)
const notificationSubscription = setupNotificationResponseListener(); const notificationSubscription = setupNotificationResponseListener();
+1 -1
View File
@@ -425,7 +425,7 @@ const styles = StyleSheet.create({
zIndex: 100, zIndex: 100,
}, },
navBarBackground: { navBarBackground: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: colors.border, borderBottomColor: colors.border,
overflow: "hidden", overflow: "hidden",
+1
View File
@@ -1,2 +1,3 @@
json_key_file(ENV["GOOGLE_PLAY_KEY_FILE"] || File.expand_path("../fastlane-key.json", __dir__)) json_key_file(ENV["GOOGLE_PLAY_KEY_FILE"] || File.expand_path("../fastlane-key.json", __dir__))
package_name("com.sauci.app") package_name("com.sauci.app")
app_identifier("com.sauci.app")
+82
View File
@@ -45,4 +45,86 @@ platform :android do
lane :upload_beta do |options| lane :upload_beta do |options|
upload(options.merge(track: "beta")) upload(options.merge(track: "beta"))
end end
desc "Upload AAB and release it: commits the release on the given track (default production), triggering Play review and rollout on approval"
lane :release do |options|
track = options[:track] || "production"
project_root = File.expand_path("..", __dir__)
aab_path = options[:aab]
UI.user_error!("Pass the exact verified artifact with aab: — never release an implicit build") unless aab_path
aab_path = File.expand_path(aab_path, project_root) unless aab_path.start_with?("/")
UI.user_error!("AAB not found at #{aab_path}") unless File.exist?(aab_path)
rollout = options[:rollout] # e.g. "0.1" for 10% staged rollout; omit for full rollout
UI.message("Releasing #{aab_path} to #{track} track#{rollout ? " at #{(rollout.to_f * 100).round}% rollout" : " at full rollout"}...")
upload_to_play_store(
track: track,
aab: aab_path,
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true,
skip_upload_changelogs: false,
release_status: rollout ? "inProgress" : "completed",
rollout: rollout,
)
UI.success("Release committed to #{track}. Google Play review runs automatically; rollout proceeds on approval.")
end
end
platform :ios do
# App Store Connect API key, supplied via environment (never committed):
# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH (path to the .p8 file)
private_lane :asc_api_key do
UI.user_error!("Set ASC_KEY_ID, ASC_ISSUER_ID, and ASC_KEY_PATH") unless ENV["ASC_KEY_ID"] && ENV["ASC_ISSUER_ID"] && ENV["ASC_KEY_PATH"]
app_store_connect_api_key(
key_id: ENV["ASC_KEY_ID"],
issuer_id: ENV["ASC_ISSUER_ID"],
key_filepath: ENV["ASC_KEY_PATH"],
)
end
desc "Upload IPA to App Store Connect / TestFlight only (no App Review submission)"
lane :upload do |options|
ipa_path = options[:ipa]
UI.user_error!("Pass the exact verified artifact with ipa:") unless ipa_path && File.exist?(File.expand_path(ipa_path))
upload_to_testflight(
api_key: asc_api_key,
ipa: File.expand_path(ipa_path),
skip_waiting_for_build_processing: false,
)
UI.success("Uploaded to App Store Connect; build is processing for TestFlight.")
end
desc "Upload IPA, create the App Store version, and submit it for App Review with automatic release on approval"
lane :release do |options|
ipa_path = options[:ipa]
UI.user_error!("Pass the exact verified artifact with ipa:") unless ipa_path && File.exist?(File.expand_path(ipa_path))
api_key = asc_api_key
upload_to_app_store(
api_key: api_key,
ipa: File.expand_path(ipa_path),
submit_for_review: true,
automatic_release: options.fetch(:automatic_release, true),
phased_release: options.fetch(:phased_release, false),
precheck_include_in_app_purchases: false,
# Metadata lives in App Store Connect; only release notes change per version.
skip_metadata: options[:release_notes].nil?,
skip_screenshots: true,
release_notes: options[:release_notes] ? { "en-GB" => options[:release_notes], "default" => options[:release_notes] } : nil,
submission_information: {
add_id_info_uses_idfa: false,
export_compliance_uses_encryption: false,
},
force: true, # skip interactive HTML preview; agents run non-interactively
)
UI.success("Submitted for App Review. The version releases #{options.fetch(:automatic_release, true) ? "automatically on approval" : "manually after approval"}.")
end
end end
+3
View File
@@ -28,3 +28,6 @@ project.xcworkspace
# CocoaPods # CocoaPods
/Pods/ /Pods/
# @bacons/apple-targets generated entitlements cache
/.targets/
+21 -33
View File
@@ -1,15 +1,28 @@
# Set by expo-router. This enables Fabric-only features from react-native-screens
ENV['RNS_GAMMA_ENABLED'] ||= '1'
require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking") require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking")
require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods") require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods")
require 'json' require 'json'
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {} podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
ENV['RCT_NEW_ARCH_ENABLED'] = podfile_properties['newArchEnabled'] == 'true' ? '1' : '0' def ccache_enabled?(podfile_properties)
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR'] # Environment variable takes precedence
return ENV['USE_CCACHE'] == '1' if ENV['USE_CCACHE']
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1' # Fall back to Podfile properties
install! 'cocoapods', podfile_properties['apple.ccacheEnabled'] == 'true'
:deterministic_uuids => false end
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
ENV['RCT_USE_RN_DEP'] ||= podfile_properties['ios.buildReactNativeFromSource'] == 'true' ? '0' : '1'
ENV['RCT_USE_PREBUILT_RNCORE'] ||= podfile_properties['ios.buildReactNativeFromSource'] == 'true' ? '0' : '1'
ENV['RCT_HERMES_V1_ENABLED'] ||= '0' if podfile_properties['expo.useHermesV1'] == 'false'
# An explicit `ios.usePrecompiledModules: false` must win even when the env var is already set
# (e.g. EXPO_USE_PRECOMPILED_MODULES exported by EAS Build), so force-disable here before the default.
ENV['EXPO_USE_PRECOMPILED_MODULES'] = '0' if podfile_properties['EXPO_USE_PRECOMPILED_MODULES'] == 'false'
ENV['EXPO_USE_PRECOMPILED_MODULES'] ||= '1'
platform :ios, podfile_properties['ios.deploymentTarget'] || '16.4'
prepare_react_native_project! prepare_react_native_project!
@@ -23,7 +36,8 @@ target 'Sauci' do
'node', 'node',
'--no-warnings', '--no-warnings',
'--eval', '--eval',
'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))', 'require(\'expo/bin/autolinking\')',
'expo-modules-autolinking',
'react-native-config', 'react-native-config',
'--json', '--json',
'--platform', '--platform',
@@ -49,34 +63,8 @@ target 'Sauci' do
installer, installer,
config[:reactNativePath], config[:reactNativePath],
:mac_catalyst_enabled => false, :mac_catalyst_enabled => false,
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true', :ccache_enabled => ccache_enabled?(podfile_properties),
) )
# The fmt version bundled with React Native 0.76 enables consteval for
# Apple Clang, but Apple Clang 21 (Xcode 26.4+) rejects fmt's FMT_STRING
# call sites. Patch the generated pod until React Native supplies a newer
# fmt release. Keeping this here makes clean pod installs reproducible.
fmt_base_header = File.join(__dir__, 'Pods', 'fmt', 'include', 'fmt', 'base.h')
if File.exist?(fmt_base_header)
fmt_base_source = File.read(fmt_base_header)
fmt_base_source.sub!(
/#elif defined\(__apple_build_version__\) && __apple_build_version__ < 14000029L/,
'#elif defined(__apple_build_version__)'
)
File.chmod(0644, fmt_base_header)
File.write(fmt_base_header, fmt_base_source)
end
# This is necessary for Xcode 14, because it signs resource bundles by default
# when building for devices.
installer.target_installation_results.pod_target_installation_results
.each do |pod_name, target_installation_result|
target_installation_result.resource_bundle_targets.each do |resource_bundle_target|
resource_bundle_target.build_configurations.each do |config|
config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
end
end
end
end end
end end
+1210 -1720
View File
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -1,9 +1,10 @@
{ {
"expo.jsEngine": "hermes", "expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"newArchEnabled": "true", "expo.inlineModules.watchedDirectories": "[]",
"ios.useFrameworks": "static", "expo.inlineModules.xcodeProjectTargets": "{\"mainTarget\":\"Sauci\",\"targets\":[]}",
"apple.extraPods": "[]", "ios.forceStaticLinking": "[]",
"apple.ccacheEnabled": "false", "apple.privacyManifestAggregationEnabled": "true",
"apple.privacyManifestAggregationEnabled": "true" "EXPO_USE_PRECOMPILED_MODULES": "true",
"ios.useFrameworks": "static"
} }
+227 -145
View File
@@ -3,19 +3,18 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 46; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = F744EAD4ABA54509966475D6 /* noop-file.swift */; }; 3F202C2A859975F48A83ADD7 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 779267BD380A5D29590F1BDC /* ExpoModulesProvider.swift */; };
7B71EEB2AF682FA7AFF556D5 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */; }; 45941E25D336B6152BFCEFB9 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B482C7D915C22B3F33CA9841 /* Pods_Sauci.framework */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; 804CD03F6B584D08B7803C63 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 69E91809C21A4977A3249D53 /* PrivacyInfo.xcprivacy */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
CEF2F278ABBC5FBFD470A0F7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */; }; D60EEEF252214D45B67A0063 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = D58CE235EF5F40F58FB8111D /* GoogleService-Info.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
FDFABE1A1A3547B1B22DD2DD /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */; };
XX12B55A1EDABF083C3B51XX /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */; }; XX12B55A1EDABF083C3B51XX /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */; };
XX1A6800511A685AB346D2XX /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXA12889081EB894481655XX /* AppIntents.framework */; }; XX1A6800511A685AB346D2XX /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXA12889081EB894481655XX /* AppIntents.framework */; };
XX3C562DEF346B45594832XX /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXCAF23C3FC41F82140FA1XX /* ActivityKit.framework */; }; XX3C562DEF346B45594832XX /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXCAF23C3FC41F82140FA1XX /* ActivityKit.framework */; };
@@ -24,11 +23,11 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
XXA7C7D5580082A497FD95XX /* PBXContainerItemProxy */ = { XX77D941E1DB38490B8F4EXX /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1; proxyType = 1;
remoteGlobalIDString = XX540FB4ABC75CE91A6036XX; remoteGlobalIDString = XXB3405CC208C2ECA4797FXX;
remoteInfo = LiveDrawWidget; remoteInfo = LiveDrawWidget;
}; };
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
@@ -51,18 +50,17 @@
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; fileEncoding = 4; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; fileEncoding = 4; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; }; 69E91809C21A4977A3249D53 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; }; 779267BD380A5D29590F1BDC /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; }; A172EA788EF476A7083810FE /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
B482C7D915C22B3F33CA9841 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = undefined; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; D58CE235EF5F40F58FB8111D /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
DA536CAC88B349A59DFC1F1E /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; fileEncoding = 4; includeInIndex = undefined; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Sauci/AppDelegate.swift; sourceTree = "<group>"; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Sauci/AppDelegate.swift; sourceTree = "<group>"; };
F744EAD4ABA54509966475D6 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; }; F11748442D0722820044C1D9 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; }; FEACE2E5B4EA50AFBF4DDF34 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
XX4DFF38D47332D6BF0183XX /* SwiftUI.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; XX4DFF38D47332D6BF0183XX /* SwiftUI.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
XXA12889081EB894481655XX /* AppIntents.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; }; XXA12889081EB894481655XX /* AppIntents.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; };
@@ -71,21 +69,21 @@
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
XX020388E3BB9C764E774FXX /* Exceptions for "widget" folder in "LiveDrawWidget" target */ = { XX6FB9E6678087E3AAC658XX /* Exceptions for "widget" folder in "LiveDrawWidget" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Info.plist, Info.plist,
"expo-target.config.js", "expo-target.config.js",
); );
target = XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */; target = XXB3405CC208C2ECA4797FXX /* LiveDrawWidget */;
}; };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
XX683E29AD7AD8279E17B2XX /* widget */ = { XXBB44BF07EB68666150ABXX /* widget */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
XX020388E3BB9C764E774FXX /* Exceptions for "widget" folder in "LiveDrawWidget" target */, XX6FB9E6678087E3AAC658XX /* Exceptions for "widget" folder in "LiveDrawWidget" target */,
); );
explicitFileTypes = { explicitFileTypes = {
}; };
@@ -101,7 +99,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
7B71EEB2AF682FA7AFF556D5 /* Pods_Sauci.framework in Frameworks */, 45941E25D336B6152BFCEFB9 /* Pods_Sauci.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -122,15 +120,32 @@
13B07FAE1A68108700A75B9A /* Sauci */ = { 13B07FAE1A68108700A75B9A /* Sauci */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
BB2F792B24A3F905000567C9 /* Supporting */,
F11748412D0307B40044C1D9 /* AppDelegate.swift */, F11748412D0307B40044C1D9 /* AppDelegate.swift */,
F11748442D0722820044C1D9 /* Sauci-Bridging-Header.h */,
BB2F792B24A3F905000567C9 /* Supporting */,
13B07FB51A68108700A75B9A /* Images.xcassets */, 13B07FB51A68108700A75B9A /* Images.xcassets */,
13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB61A68108700A75B9A /* Info.plist */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */, 69E91809C21A4977A3249D53 /* PrivacyInfo.xcprivacy */,
F744EAD4ABA54509966475D6 /* noop-file.swift */, D58CE235EF5F40F58FB8111D /* GoogleService-Info.plist */,
DA536CAC88B349A59DFC1F1E /* Sauci-Bridging-Header.h */, );
D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */, name = Sauci;
sourceTree = "<group>";
};
1957149DB83AAF8603C60859 /* Pods */ = {
isa = PBXGroup;
children = (
FEACE2E5B4EA50AFBF4DDF34 /* Pods-Sauci.debug.xcconfig */,
A172EA788EF476A7083810FE /* Pods-Sauci.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
2BFCD64E7EEB594CC2D9E261 /* Sauci */ = {
isa = PBXGroup;
children = (
779267BD380A5D29590F1BDC /* ExpoModulesProvider.swift */,
); );
name = Sauci; name = Sauci;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -141,9 +156,9 @@
ED297162215061F000B7C4FE /* JavaScriptCore.framework */, ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */, XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */,
XX4DFF38D47332D6BF0183XX /* SwiftUI.framework */, XX4DFF38D47332D6BF0183XX /* SwiftUI.framework */,
DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */,
XXCAF23C3FC41F82140FA1XX /* ActivityKit.framework */, XXCAF23C3FC41F82140FA1XX /* ActivityKit.framework */,
XXA12889081EB894481655XX /* AppIntents.framework */, XXA12889081EB894481655XX /* AppIntents.framework */,
B482C7D915C22B3F33CA9841 /* Pods_Sauci.framework */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -163,8 +178,8 @@
832341AE1AAA6A7D00B99B32 /* Libraries */, 832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */, 83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */, 2D16E6871FA4F8E400B85C8A /* Frameworks */,
D65327D7A22EEC0BE12398D9 /* Pods */, 1957149DB83AAF8603C60859 /* Pods */,
D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */, CC8FEB5C5178B9E5E71D4F61 /* ExpoModulesProviders */,
); );
indentWidth = 2; indentWidth = 2;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -180,14 +195,6 @@
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
92DBD88DE9BF7D494EA9DA96 /* Sauci */ = {
isa = PBXGroup;
children = (
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */,
);
name = Sauci;
sourceTree = "<group>";
};
BB2F792B24A3F905000567C9 /* Supporting */ = { BB2F792B24A3F905000567C9 /* Supporting */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -197,19 +204,10 @@
path = Sauci/Supporting; path = Sauci/Supporting;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D65327D7A22EEC0BE12398D9 /* Pods */ = { CC8FEB5C5178B9E5E71D4F61 /* ExpoModulesProviders */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */, 2BFCD64E7EEB594CC2D9E261 /* Sauci */,
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */ = {
isa = PBXGroup;
children = (
92DBD88DE9BF7D494EA9DA96 /* Sauci */,
); );
name = ExpoModulesProviders; name = ExpoModulesProviders;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -217,7 +215,7 @@
XX3E41C1850246162C0061XX /* expo:targets */ = { XX3E41C1850246162C0061XX /* expo:targets */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
XX683E29AD7AD8279E17B2XX /* widget */, XXBB44BF07EB68666150ABXX /* widget */,
); );
name = "expo:targets"; name = "expo:targets";
path = ../targets; path = ../targets;
@@ -231,30 +229,31 @@
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
buildPhases = ( buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
DC3D179C6B62F16DA5F17C2F /* [Expo] Configure project */, E6F7433229E3C1448FC9F062 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */, 13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */, 13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
D58CF933D4804A0CB49C2891 /* [Expo Dev Launcher] Strip Local Network Keys for Release */,
XX72A8CFBF0FBA13A79902XX /* Embed Foundation Extensions */, XX72A8CFBF0FBA13A79902XX /* Embed Foundation Extensions */,
A1248897D24903117793599B /* [CP] Embed Pods Frameworks */, 503A0FD3DBA862B4BE649BBB /* [CP] Embed Pods Frameworks */,
FB4B019D835055733ACDE109 /* [CP-User] [RNFB] Core Configuration */, 8490E10253F91A7BEB80F555 /* [CP-User] [RNFB] Core Configuration */,
563ACE2651AC39438F3B399B /* [CP-User] [RNFB] Crashlytics Configuration */, 0EE1B67C9AAD8BD8562E800E /* [CP-User] [RNFB] Crashlytics Configuration */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
XX97289CEC7994AD5F4142XX /* PBXTargetDependency */, XX5D3A21868E45E6E5BDC3XX /* PBXTargetDependency */,
); );
name = Sauci; name = Sauci;
productName = Sauci; productName = Sauci;
productReference = 13B07F961A680F5B00A75B9A /* Sauci.app */; productReference = 13B07F961A680F5B00A75B9A /* Sauci.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */ = { XXB3405CC208C2ECA4797FXX /* LiveDrawWidget */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = XX63FAB1BD532DA0D8AE18XX /* Build configuration list for PBXNativeTarget "LiveDrawWidget" */; buildConfigurationList = XX35D62F4FBF2C9086E8C9XX /* Build configuration list for PBXNativeTarget "LiveDrawWidget" */;
buildPhases = ( buildPhases = (
XX074B8811C1CF8E8A3D74XX /* Frameworks */, XX074B8811C1CF8E8A3D74XX /* Frameworks */,
XXE45443DC38DCFE1DB622XX /* Sources */, XXE45443DC38DCFE1DB622XX /* Sources */,
@@ -265,7 +264,7 @@
dependencies = ( dependencies = (
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
XX683E29AD7AD8279E17B2XX /* widget */, XXBB44BF07EB68666150ABXX /* widget */,
); );
name = LiveDrawWidget; name = LiveDrawWidget;
productName = LiveDrawWidget; productName = LiveDrawWidget;
@@ -283,7 +282,7 @@
13B07F861A680F5B00A75B9A = { 13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250; LastSwiftMigration = 1250;
}; };
XX540FB4ABC75CE91A6036XX = { XXB3405CC208C2ECA4797FXX = {
CreatedOnToolsVersion = 14.3; CreatedOnToolsVersion = 14.3;
DevelopmentTeam = BXVPBATDZY; DevelopmentTeam = BXVPBATDZY;
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
@@ -304,7 +303,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
13B07F861A680F5B00A75B9A /* Sauci */, 13B07F861A680F5B00A75B9A /* Sauci */,
XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */, XXB3405CC208C2ECA4797FXX /* LiveDrawWidget */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -317,8 +316,8 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
FDFABE1A1A3547B1B22DD2DD /* GoogleService-Info.plist in Resources */, 804CD03F6B584D08B7803C63 /* PrivacyInfo.xcprivacy in Resources */,
CEF2F278ABBC5FBFD470A0F7 /* PrivacyInfo.xcprivacy in Resources */, D60EEEF252214D45B67A0063 /* GoogleService-Info.plist in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -341,6 +340,8 @@
inputFileListPaths = ( inputFileListPaths = (
); );
inputPaths = ( inputPaths = (
"$(SRCROOT)/.xcode.env",
"$(SRCROOT)/.xcode.env.local",
); );
name = "Bundle React Native code and images"; name = "Bundle React Native code and images";
outputFileListPaths = ( outputFileListPaths = (
@@ -373,7 +374,7 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
563ACE2651AC39438F3B399B /* [CP-User] [RNFB] Crashlytics Configuration */ = { 0EE1B67C9AAD8BD8562E800E /* [CP-User] [RNFB] Crashlytics Configuration */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -387,6 +388,52 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nset -e\n\nif [[ ${PODS_ROOT} ]]; then\n echo \"info: Exec FirebaseCrashlytics Run from Pods\"\n \"${PODS_ROOT}/FirebaseCrashlytics/run\"\nelse\n echo \"info: Exec FirebaseCrashlytics Run from framework\"\n \"${PROJECT_DIR}/FirebaseCrashlytics.framework/run\"\nfi\n"; shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nset -e\n\nif [[ ${PODS_ROOT} ]]; then\n echo \"info: Exec FirebaseCrashlytics Run from Pods\"\n \"${PODS_ROOT}/FirebaseCrashlytics/run\"\nelse\n echo \"info: Exec FirebaseCrashlytics Run from framework\"\n \"${PROJECT_DIR}/FirebaseCrashlytics.framework/run\"\nfi\n";
}; };
503A0FD3DBA862B4BE649BBB /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem.framework/ExpoFileSystem",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoFont/ExpoFont.framework/ExpoFont",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoImage/ExpoImage.framework/ExpoImage",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoImage/SDWebImage.framework/SDWebImage",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoImage/SDWebImageAVIFCoder.framework/SDWebImageAVIFCoder",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoImage/SDWebImageSVGCoder.framework/SDWebImageSVGCoder",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoImage/SDWebImageWebPCoder.framework/SDWebImageWebPCoder",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoImage/libavif.framework/libavif",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary.framework/ExpoMediaLibrary",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoModulesCore/ExpoModulesCore.framework/ExpoModulesCore",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoModulesJSI/ExpoModulesJSI.framework/ExpoModulesJSI",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ExpoModulesWorklets/ExpoModulesWorklets.framework/ExpoModulesWorklets",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermesvm.framework/hermesvm",
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ExpoFileSystem.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ExpoFont.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ExpoImage.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImageAVIFCoder.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImageSVGCoder.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImageWebPCoder.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/libavif.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ExpoMediaLibrary.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ExpoModulesCore.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ExpoModulesJSI.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ExpoModulesWorklets.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermesvm.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -398,9 +445,10 @@
"${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreExtension/FirebaseCoreExtension_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreExtension/FirebaseCoreExtension_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreInternal/FirebaseCoreInternal_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreInternal/FirebaseCoreInternal_Privacy.bundle",
@@ -414,18 +462,16 @@
"${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PromisesSwift/Promises_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PromisesSwift/Promises_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift/ReachabilitySwift.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/nanopb/nanopb_Privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/nanopb/nanopb_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/react-native-view-shot/RNViewShotPrivacyInfo.bundle",
); );
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputPaths = ( outputPaths = (
@@ -433,9 +479,10 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreExtension_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreExtension_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreInternal_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreInternal_Privacy.bundle",
@@ -449,43 +496,54 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Promises_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Promises_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReachabilitySwift.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nanopb_Privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nanopb_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNViewShotPrivacyInfo.bundle",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
A1248897D24903117793599B /* [CP] Embed Pods Frameworks */ = { 8490E10253F91A7BEB80F555 /* [CP-User] [RNFB] Core Configuration */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
inputPaths = ( inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh", "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", );
name = "[CP-User] [RNFB] Core Configuration";
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"note: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"note: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"note: -> RNFB build script started\"\necho \"note: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"note: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"note: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n if ! _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\"); then\n echo \"error: Failed to parse firebase.json, check for syntax errors.\"\n exit 1\n fi\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"error: python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACLine truncated
};
D58CF933D4804A0CB49C2891 /* [Expo Dev Launcher] Strip Local Network Keys for Release */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[Expo Dev Launcher] Strip Local Network Keys for Release";
outputFileListPaths = (
); );
name = "[CP] Embed Pods Frameworks";
outputPaths = ( outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n"; shellScript = "# Strip dev-launcher-specific local network permission keys from non-Debug builds\n# This only removes _expo._tcp Bonjour services and the dev-launcher usage description.\n# Other Bonjour services and custom descriptions are preserved for production use.\n\nif [ \"$CONFIGURATION\" != \"Debug\" ]; then\n PLIST_PATH=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n if [ -f \"$PLIST_PATH\" ]; then\n # Check if NSBonjourServices exists\n if /usr/libexec/PlistBuddy -c \"Print :NSBonjourServices\" \"$PLIST_PATH\" >/dev/null 2>&1; then\n # Get the count of services\n COUNT=$(/usr/libexec/PlistBuddy -c \"Print :NSBonjourServices\" \"$PLIST_PATH\" 2>/dev/null | grep \"^ \" | wc -l | tr -d ' ')\n\n # Remove _expo._tcp\n for ((i=COUNT-1; i>=0; i--)); do\n SERVICE=$(/usr/libexec/PlistBuddy -c \"Print :NSBonjourServices:$i\" \"$PLIST_PATH\" 2>/dev/null || echo \"\")\n if echo \"$SERVICE\" | grep -q \"_expo._tcp\"; then\n /usr/libexec/PlistBuddy -c \"Delete :NSBonjourServices:$i\" \"$PLIST_PATH\" 2>/dev/null || true\n fi\n done\n\n # If the array is now empty, remove it entirely\n REMAINING=$(/usr/libexec/PlistBuddy -c \"Print :NSBonjourServices\" \"$PLIST_PATH\" 2>/dev/null | grep \"^ \" | wc -l | tr -d ' ')\n if [ \"$REMAINING\" -eq \"0\" ]; then\n /usr/libexec/PlistBuddy -c \"Delete :NSBonjourServices\" \"$PLIST_PATH\" 2>/dev/null || true\n fi\n fi\n\n # Only delete the description if it matches the dev-launcher default text\n DESC=$(/usr/libexec/PlistBuddy -c \"Print :NSLocalNetworkUsageDescription\" \"$PLIST_PATH\" 2>/dev/null || echo \"\")\n if echo \"$DESC\" | grep -q \"Expo Dev Launcher\"; then\n /usr/libexec/PlistBuddy -c \"Delete :NSLocalNetworkUsageDescription\" \"$PLIST_PATH\" 2>/dev/null || true\n fi\n fi\nfi\n";
showEnvVarsInLog = 0;
}; };
DC3D179C6B62F16DA5F17C2F /* [Expo] Configure project */ = { E6F7433229E3C1448FC9F062 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -509,19 +567,6 @@
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n"; shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n";
}; };
FB4B019D835055733ACDE109 /* [CP-User] [RNFB] Core Configuration */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
);
name = "[CP-User] [RNFB] Core Configuration";
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"note: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"note: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"note: -> RNFB build script started\"\necho \"note: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"note: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"note: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n if ! _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\"); then\n echo \"error: Failed to parse firebase.json, check for syntax errors.\"\n exit 1\n fi\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"error: python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACLine truncated
};
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -530,8 +575,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, 3F202C2A859975F48A83ADD7 /* ExpoModulesProvider.swift in Sources */,
5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -545,17 +589,17 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
XX97289CEC7994AD5F4142XX /* PBXTargetDependency */ = { XX5D3A21868E45E6E5BDC3XX /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */; target = XXB3405CC208C2ECA4797FXX /* LiveDrawWidget */;
targetProxy = XXA7C7D5580082A497FD95XX /* PBXContainerItemProxy */; targetProxy = XX77D941E1DB38490B8F4EXX /* PBXContainerItemProxy */;
}; };
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = { 13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */; baseConfigurationReference = FEACE2E5B4EA50AFBF4DDF34 /* Pods-Sauci.debug.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
@@ -568,9 +612,13 @@
"FB_SONARKIT_ENABLED=1", "FB_SONARKIT_ENABLED=1",
); );
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 16.4;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
MARKETING_VERSION = 1.0.7; "$(inherited)",
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.4;
MARKETING_VERSION = 1.0.8;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -589,7 +637,7 @@
}; };
13B07F951A680F5B00A75B9A /* Release */ = { 13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */; baseConfigurationReference = A172EA788EF476A7083810FE /* Pods-Sauci.release.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
@@ -597,9 +645,13 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 16.4;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
MARKETING_VERSION = 1.0.7; "$(inherited)",
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.4;
MARKETING_VERSION = 1.0.8;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -664,29 +716,39 @@
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
HEADER_SEARCH_PATHS = ( HEADER_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers", "${PODS_ROOT}/ReactCommon",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core", "${PODS_ROOT}/ReactCommon/react/nativemodule/core",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers", "${PODS_ROOT}/React-runtimeexecutor",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios", "${PODS_ROOT}/React-runtimeexecutor/platform/ios",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", "${PODS_ROOT}/ReactCommon-Samples",
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", "${PODS_ROOT}/ReactCommon-Samples/platform/ios",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", "${PODS_ROOT}/React-Fabric/react/renderer/components/view/platform/cxx",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", "${PODS_ROOT}/React-NativeModulesApple",
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers", "${PODS_ROOT}/React-graphics",
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers/platform/ios", "${PODS_ROOT}/React-graphics/react/renderer/graphics/platform/ios",
"${PODS_ROOT}/React-featureflags",
"${PODS_ROOT}/React-renderercss",
);
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
); );
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = ( OTHER_CFLAGS = (
"$(inherited)", "$(inherited)",
" ", "-DRCT_REMOVE_LEGACY_ARCH=1",
);
OTHER_CPLUSPLUSFLAGS = (
"$(inherited)",
"-DRCT_REMOVE_LEGACY_ARCH=1",
); );
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true; USE_HERMES = true;
}; };
name = Debug; name = Debug;
@@ -733,33 +795,43 @@
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
HEADER_SEARCH_PATHS = ( HEADER_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers", "${PODS_ROOT}/ReactCommon",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core", "${PODS_ROOT}/ReactCommon/react/nativemodule/core",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers", "${PODS_ROOT}/React-runtimeexecutor",
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios", "${PODS_ROOT}/React-runtimeexecutor/platform/ios",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx", "${PODS_ROOT}/ReactCommon-Samples",
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers", "${PODS_ROOT}/ReactCommon-Samples/platform/ios",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers", "${PODS_ROOT}/React-Fabric/react/renderer/components/view/platform/cxx",
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios", "${PODS_ROOT}/React-NativeModulesApple",
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers", "${PODS_ROOT}/React-graphics",
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers/platform/ios", "${PODS_ROOT}/React-graphics/react/renderer/graphics/platform/ios",
"${PODS_ROOT}/React-featureflags",
"${PODS_ROOT}/React-renderercss",
);
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
); );
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = ( OTHER_CFLAGS = (
"$(inherited)", "$(inherited)",
" ", "-DRCT_REMOVE_LEGACY_ARCH=1",
);
OTHER_CPLUSPLUSFLAGS = (
"$(inherited)",
"-DRCT_REMOVE_LEGACY_ARCH=1",
); );
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
USE_HERMES = true; USE_HERMES = true;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
}; };
XX1230635DC14DD95E28A2XX /* Release */ = { XX0A0FB078256184BF7EA2XX /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
@@ -772,7 +844,7 @@
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements; CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 46; CURRENT_PROJECT_VERSION = 49;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -781,12 +853,17 @@
INFOPLIST_KEY_CFBundleDisplayName = LiveDrawWidget; INFOPLIST_KEY_CFBundleDisplayName = LiveDrawWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
MARKETING_VERSION = 1.0.7; "$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.8;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget; PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -796,7 +873,7 @@
}; };
name = Release; name = Release;
}; };
XX710DB962925BCE8D59ACXX /* Debug */ = { XX42CD38636C47E26CBE73XX /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
@@ -808,7 +885,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements; CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46; CURRENT_PROJECT_VERSION = 49;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -817,13 +894,18 @@
INFOPLIST_KEY_CFBundleDisplayName = LiveDrawWidget; INFOPLIST_KEY_CFBundleDisplayName = LiveDrawWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
MARKETING_VERSION = 1.0.7; "$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.8;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget; PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -854,11 +936,11 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
XX63FAB1BD532DA0D8AE18XX /* Build configuration list for PBXNativeTarget "LiveDrawWidget" */ = { XX35D62F4FBF2C9086E8C9XX /* Build configuration list for PBXNativeTarget "LiveDrawWidget" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
XX710DB962925BCE8D59ACXX /* Debug */, XX42CD38636C47E26CBE73XX /* Debug */,
XX1230635DC14DD95E28A2XX /* Release */, XX0A0FB078256184BF7EA2XX /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
+16 -18
View File
@@ -1,10 +1,10 @@
import Expo internal import Expo
import FirebaseCore import FirebaseCore
import React import React
import ReactAppDependencyProvider import ReactAppDependencyProvider
@UIApplicationMain @main
public class AppDelegate: ExpoAppDelegate { class AppDelegate: ExpoAppDelegate {
var window: UIWindow? var window: UIWindow?
var reactNativeDelegate: ExpoReactNativeFactoryDelegate? var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
@@ -20,11 +20,12 @@ public class AppDelegate: ExpoAppDelegate {
reactNativeDelegate = delegate reactNativeDelegate = delegate
reactNativeFactory = factory reactNativeFactory = factory
bindReactNativeFactory(factory)
#if os(iOS) || os(tvOS) #if os(iOS) || os(tvOS)
window = UIWindow(frame: UIScreen.main.bounds) window = UIWindow(frame: UIScreen.main.bounds)
FirebaseApp.configure() // @generated begin @react-native-firebase/app-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-10e8520570672fd76b2403b7e1e27f5198a6349a
FirebaseApp.configure()
// @generated end @react-native-firebase/app-didFinishLaunchingWithOptions
factory.startReactNative( factory.startReactNative(
withModuleName: "main", withModuleName: "main",
in: window, in: window,
@@ -34,42 +35,39 @@ public class AppDelegate: ExpoAppDelegate {
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
// Linking API
public override func application( public override func application(
_ app: UIApplication, _ app: UIApplication,
open url: URL, open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:] options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool { ) -> Bool {
super.application(app, open: url, options: options) return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options)
|| RCTLinkingManager.application(app, open: url, options: options)
} }
// Universal Links
public override func application( public override func application(
_ application: UIApplication, _ application: UIApplication,
continue userActivity: NSUserActivity, continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool { ) -> Bool {
let result = RCTLinkingManager.application( let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
application, return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result
continue: userActivity,
restorationHandler: restorationHandler)
return super.application(
application,
continue: userActivity,
restorationHandler: restorationHandler) || result
} }
} }
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
// Extension point for config-plugins
override func sourceURL(for bridge: RCTBridge) -> URL? { override func sourceURL(for bridge: RCTBridge) -> URL? {
// needed to return the correct URL for expo-dev-client.
bridge.bundleURL ?? bundleURL() bridge.bundleURL ?? bundleURL()
} }
override func bundleURL() -> URL? { override func bundleURL() -> URL? {
#if DEBUG #if DEBUG
RCTBundleURLProvider.sharedSettings() return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
.jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
#else #else
Bundle.main.url(forResource: "main", withExtension: "jsbundle") return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif #endif
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 57 KiB

+2 -9
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.7</string> <string>1.0.8</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -47,7 +47,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>46</string> <string>49</string>
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key> <key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
<false/> <false/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
@@ -103,13 +103,6 @@
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key> <key>UIUserInterfaceStyle</key>
<string>Automatic</string> <string>Automatic</string>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
+170 -9
View File
@@ -24,6 +24,14 @@
<string>C56D.1</string> <string>C56D.1</string>
</array> </array>
</dict> </dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict> <dict>
<key>NSPrivacyAccessedAPIType</key> <key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string> <string>NSPrivacyAccessedAPICategoryDiskSpace</string>
@@ -33,18 +41,171 @@
<string>85F4.1</string> <string>85F4.1</string>
</array> </array>
</dict> </dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array> </array>
<key>NSPrivacyCollectedDataTypes</key> <key>NSPrivacyCollectedDataTypes</key>
<array/> <array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeEmailAddress</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeName</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeSensitiveInfo</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
<string>NSPrivacyCollectedDataTypePurposeProductPersonalization</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeUserID</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeDeviceID</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePhotosorVideos</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeAudioData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherUserContent</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeProductInteraction</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeCrashData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePerformanceData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherDiagnosticData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypePurchaseHistory</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<true/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
</dict>
</array>
<key>NSPrivacyTracking</key> <key>NSPrivacyTracking</key>
<false/> <false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
</dict> </dict>
</plist> </plist>
@@ -1,3 +1,3 @@
// //
// Use this file to import your target's public headers that you would like to expose to Swift. // Use this file to import your target's public headers that you would like to expose to Swift.
// //
+1 -1
View File
@@ -17,4 +17,4 @@
<string>group.com.sauci.app</string> <string>group.com.sauci.app</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+10 -8
View File
@@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24093.7" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="EXPO-VIEWCONTROLLER-1">
<device id="retina6_12" orientation="portrait" appearance="light"/> <device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24053.1"/>
<capability name="Named colors" minToolsVersion="9.0"/> <capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
<scenes> <scenes>
@@ -17,15 +18,13 @@
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews> <subviews>
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLogo" image="SplashScreenLogo" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false"> <imageView id="EXPO-SplashScreen" userLabel="SplashScreenLogo" image="SplashScreenLogo" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
<rect key="frame" x="0" y="0" width="414" height="736"/> <rect key="frame" x="146.5" y="376" width="100" height="100"/>
</imageView> </imageView>
</subviews> </subviews>
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/> <viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
<constraints> <constraints>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="83fcb9b545b870ba44c24f0feeb116490c499c52"/> <constraint firstItem="EXPO-SplashScreen" firstAttribute="centerX" secondItem="EXPO-ContainerView" secondAttribute="centerX" id="cad2ab56f97c5429bf29decf850647a4216861d4"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="leading" secondItem="EXPO-ContainerView" secondAttribute="leading" id="61d16215e44b98e39d0a2c74fdbfaaa22601b12c"/> <constraint firstItem="EXPO-SplashScreen" firstAttribute="centerY" secondItem="EXPO-ContainerView" secondAttribute="centerY" id="1a145271b085b6ce89b1405a310f5b1bb7656595"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="trailing" secondItem="EXPO-ContainerView" secondAttribute="trailing" id="f934da460e9ab5acae3ad9987d5b676a108796c1"/>
<constraint firstItem="EXPO-SplashScreen" firstAttribute="bottom" secondItem="EXPO-ContainerView" secondAttribute="bottom" id="d6a0be88096b36fb132659aa90203d39139deda9"/>
</constraints> </constraints>
<color key="backgroundColor" name="SplashScreenBackground"/> <color key="backgroundColor" name="SplashScreenBackground"/>
</view> </view>
@@ -36,7 +35,10 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="SplashScreenLogo" width="414" height="736"/> <image name="SplashScreenLogo" width="100" height="100"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<namedColor name="SplashScreenBackground"> <namedColor name="SplashScreenBackground">
<color alpha="1.000" blue="0.180392156862745" green="0.101960784313725" red="0.101960784313725" customColorSpace="sRGB" colorSpace="custom"/> <color alpha="1.000" blue="0.180392156862745" green="0.101960784313725" red="0.101960784313725" customColorSpace="sRGB" colorSpace="custom"/>
</namedColor> </namedColor>
+3 -1
View File
@@ -31,6 +31,8 @@ Ug==
<key>alg</key> <key>alg</key>
<string>rsa-v1_5-sha256</string> <string>rsa-v1_5-sha256</string>
</dict> </dict>
<key>EXUpdatesEnableBsdiffPatchSupport</key>
<true/>
<key>EXUpdatesEnabled</key> <key>EXUpdatesEnabled</key>
<true/> <true/>
<key>EXUpdatesLaunchWaitMs</key> <key>EXUpdatesLaunchWaitMs</key>
@@ -45,7 +47,7 @@ Ug==
<string/> <string/>
</dict> </dict>
<key>EXUpdatesRuntimeVersion</key> <key>EXUpdatesRuntimeVersion</key>
<string>1.0.7</string> <string>1.0.8</string>
<key>EXUpdatesURL</key> <key>EXUpdatesURL</key>
<string>https://ota.apps.jbrunton.co.uk/manifest</string> <string>https://ota.apps.jbrunton.co.uk/manifest</string>
</dict> </dict>
-4
View File
@@ -1,4 +0,0 @@
//
// @generated
// A blank Swift file must be created for native modules with Swift files to work correctly.
//
+6
View File
@@ -12,6 +12,12 @@ module.exports = {
], ],
moduleNameMapper: { moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1', '^@/(.*)$': '<rootDir>/src/$1',
// react-native-worklets ships a JS-only mock for its native module at
// this path (react-native-reanimated depends on it internally and has
// no equivalent of its own). Without this, requiring the real
// react-native-worklets under Jest throws because no native module is
// registered.
'^react-native-worklets$': '<rootDir>/../../node_modules/react-native-worklets/src/mock.ts',
}, },
testMatch: [ testMatch: [
'<rootDir>/src/**/__tests__/**/*.(spec|test).(ts|tsx)', '<rootDir>/src/**/__tests__/**/*.(spec|test).(ts|tsx)',
+52 -45
View File
@@ -19,6 +19,11 @@
"release:android": "dotenv -e .env -- eas build --platform android --profile production --local", "release:android": "dotenv -e .env -- eas build --platform android --profile production --local",
"release:android:preview": "dotenv -e .env -- eas build --platform android --profile preview --local", "release:android:preview": "dotenv -e .env -- eas build --platform android --profile preview --local",
"release:android:upload": "bundle exec fastlane android upload", "release:android:upload": "bundle exec fastlane android upload",
"release:android:ship": "bundle exec fastlane android release",
"release:ios:upload": "bundle exec fastlane ios upload",
"release:ios:ship": "bundle exec fastlane ios release",
"release:preflight": "node scripts/release-preflight.mjs",
"release:preflight:local-eas": "node scripts/release-preflight.mjs --local-eas",
"ota:preflight:staging": "node scripts/ota-publish.mjs staging --preflight", "ota:preflight:staging": "node scripts/ota-publish.mjs staging --preflight",
"ota:preflight:production": "node scripts/ota-publish.mjs production --preflight", "ota:preflight:production": "node scripts/ota-publish.mjs production --preflight",
"ota:publish:staging": "node scripts/ota-publish.mjs staging", "ota:publish:staging": "node scripts/ota-publish.mjs staging",
@@ -29,7 +34,7 @@
"ota:doctor:production": "node scripts/ota-publish.mjs production --doctor" "ota:doctor:production": "node scripts/ota-publish.mjs production --doctor"
}, },
"dependencies": { "dependencies": {
"@bacons/apple-targets": "^0.1.6", "@bacons/apple-targets": "^5.0.0",
"@bittingz/expo-widgets": "^3.0.2", "@bittingz/expo-widgets": "^3.0.2",
"@expo/ngrok": "^4.1.0", "@expo/ngrok": "^4.1.0",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
@@ -39,54 +44,55 @@
"@react-native-firebase/app": "^23.8.2", "@react-native-firebase/app": "^23.8.2",
"@react-native-firebase/crashlytics": "^23.8.4", "@react-native-firebase/crashlytics": "^23.8.4",
"@react-native-google-signin/google-signin": "^16.1.1", "@react-native-google-signin/google-signin": "^16.1.1",
"@react-navigation/bottom-tabs": "^7.0.0", "@shopify/react-native-skia": "^2.6.9",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"@shopify/react-native-skia": "2.2.12",
"@supabase/supabase-js": "^2.39.0", "@supabase/supabase-js": "^2.39.0",
"@tanstack/react-query": "^5.17.0", "@tanstack/react-query": "^5.17.0",
"babel-preset-expo": "~54.0.12", "babel-preset-expo": "~56.0.0",
"base64-arraybuffer": "^1.0.2", "base64-arraybuffer": "^1.0.2",
"expo": "~54.0.0", "expo": "^56.0.0",
"expo-apple-authentication": "~8.0.8", "expo-apple-authentication": "~56.0.4",
"expo-asset": "~12.0.13", "expo-asset": "~56.0.24",
"expo-av": "~16.0.8", "expo-audio": "~56.0.13",
"expo-blur": "~15.0.8", "expo-blur": "~56.0.4",
"expo-build-properties": "~1.0.10", "expo-build-properties": "~56.0.27",
"expo-clipboard": "~8.0.8", "expo-clipboard": "~56.0.4",
"expo-constants": "~18.0.14", "expo-constants": "~56.0.25",
"expo-dev-client": "~6.0.21", "expo-dev-client": "~56.0.26",
"expo-file-system": "~19.0.24", "expo-file-system": "~56.0.11",
"expo-font": "~14.0.12", "expo-font": "~56.0.7",
"expo-haptics": "~15.0.8", "expo-haptics": "~56.0.3",
"expo-image": "~3.0.11", "expo-image": "~56.0.12",
"expo-image-picker": "~17.0.11", "expo-image-picker": "~56.0.25",
"expo-linear-gradient": "~15.0.8", "expo-linear-gradient": "~56.0.4",
"expo-linking": "~8.0.12", "expo-linking": "~56.0.17",
"expo-local-authentication": "~17.0.9", "expo-local-authentication": "~56.0.5",
"expo-media-library": "~18.2.1", "expo-media-library": "~56.0.11",
"expo-notifications": "~0.32.17", "expo-notifications": "~56.0.25",
"expo-router": "~6.0.24", "expo-observe": "~56.0.29",
"expo-secure-store": "~15.0.8", "expo-router": "~56.2.20",
"expo-sharing": "~14.0.8", "expo-secure-store": "~56.0.4",
"expo-status-bar": "~3.0.9", "expo-sharing": "~56.0.26",
"expo-updates": "~29.0.20", "expo-splash-screen": "~56.0.15",
"react": "19.1.0", "expo-status-bar": "~56.0.4",
"react-dom": "19.1.0", "expo-system-ui": "~56.0.5",
"react-native": "0.81.5", "expo-updates": "~56.0.26",
"expo-video": "~56.1.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
"react-native-android-widget": "^0.22.1", "react-native-android-widget": "^0.22.1",
"react-native-compressor": "^1.13.0", "react-native-compressor": "^1.13.0",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.31.1",
"react-native-purchases": "^9.6.12", "react-native-purchases": "^9.6.12",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "4.3.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.26.0",
"react-native-share": "^12.3.1", "react-native-share": "^12.3.1",
"react-native-svg": "15.12.1", "react-native-svg": "15.15.4",
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "^4.0.3", "react-native-view-shot": "5.1.0",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.8.3",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
"devDependencies": { "devDependencies": {
@@ -94,15 +100,16 @@
"@testing-library/jest-native": "^5.4.3", "@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3", "@testing-library/react-native": "^13.3.3",
"@types/jest": "29.5.14", "@types/jest": "29.5.14",
"@types/react": "~19.1.10", "@types/node": "^20.19.0",
"@types/react": "~19.2.14",
"@types/react-test-renderer": "^19.1.0", "@types/react-test-renderer": "^19.1.0",
"dotenv-cli": "^11.0.0", "dotenv-cli": "^11.0.0",
"eoas": "^3.1.3", "eoas": "^3.1.3",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~56.0.4",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expo": "~54.0.18", "jest-expo": "~56.0.5",
"react-test-renderer": "19.1.0", "react-test-renderer": "19.2.3",
"typescript": "~5.9.2" "typescript": "~6.0.3"
} }
} }
+6 -1
View File
@@ -3,6 +3,7 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
const projectDir = resolve(import.meta.dirname, '..'); const projectDir = resolve(import.meta.dirname, '..');
@@ -140,4 +141,8 @@ export function main(args = process.argv.slice(2), environment = process.env) {
run('npx', ['eoas', 'publish', '--branch', branch, ...parsePublishArgs(rawPublishArgs)], exportEnv); run('npx', ['eoas', 'publish', '--branch', branch, ...parsePublishArgs(rawPublishArgs)], exportEnv);
} }
if (process.argv[1] === new URL(import.meta.url).pathname) main(); // Compare real filesystem paths. A URL pathname percent-encodes spaces, so
// comparing it to process.argv[1] silently skips main() under any checkout
// whose absolute path contains a space, and every ota:* script then exits 0
// having done nothing.
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
import { existsSync, readFileSync, statSync, statfsSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve, join } from 'node:path';
const projectDir = resolve(import.meta.dirname, '..');
const repoRoot = resolve(projectDir, '../..');
const certificatePath = 'apps/mobile/certs/certificate.pem';
const semverPattern = /^\d+\.\d+\.\d+$/;
const minimumFreeBuildBytes = 20 * 1024 ** 3;
function fail(message) {
throw new Error(`Mobile release preflight: ${message}`);
}
function plistValue(source, key, label) {
const values = [...source.matchAll(new RegExp(`<key>${key}</key>\\s*<string>([^<]+)</string>`, 'g'))].map((match) => match[1]);
if (values.length !== 1) fail(`expected one ${label} ${key}`);
return values[0];
}
function allMatches(source, expression, label) {
const values = [...source.matchAll(expression)].map((match) => match[1].trim());
if (!values.length) fail(`found no ${label}`);
return values;
}
function gitStatus(args, root = repoRoot) {
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
if (result.error) fail(`could not run git ${args.join(' ')}: ${result.error.message}`);
return result.status;
}
export function validateReleaseSources({ root = repoRoot, isTracked } = {}) {
const file = (relative) => readFileSync(join(root, relative), 'utf8');
const app = JSON.parse(file('apps/mobile/app.json')).expo;
const appConfig = file('apps/mobile/app.config.js');
const androidGradle = file('apps/mobile/android/app/build.gradle');
const androidStrings = file('apps/mobile/android/app/src/main/res/values/strings.xml');
const xcode = file('apps/mobile/ios/Sauci.xcodeproj/project.pbxproj');
const androidManifest = file('apps/mobile/android/app/src/main/AndroidManifest.xml');
const iosInfo = file('apps/mobile/ios/Sauci/Info.plist');
const widgetInfo = file('apps/mobile/targets/widget/Info.plist');
const iosExpo = file('apps/mobile/ios/Sauci/Supporting/Expo.plist');
const publicVersion = app.version;
const appConfigRuntime = allMatches(appConfig, /runtimeVersion:\s*['"]([^'"]+)['"]/g, 'explicit app.config.js runtimeVersion');
const androidVersion = allMatches(androidGradle, /\bversionName\s+["']([^"']+)["']/g, 'Android versionName');
const androidRuntime = allMatches(androidStrings, /<string name="expo_runtime_version">([^<]+)<\/string>/g, 'Android Expo runtime version');
const xcodeMarketing = allMatches(xcode, /\bMARKETING_VERSION\s*=\s*([^;]+);/g, 'iOS MARKETING_VERSION settings');
const widgetBuildSettings = [...xcode.matchAll(/CURRENT_PROJECT_VERSION\s*=\s*([^;]+);[\s\S]{0,900}?PRODUCT_BUNDLE_IDENTIFIER\s*=\s*com\.sauci\.app\.LiveDrawWidget;/g)].map((match) => match[1].trim());
if (widgetBuildSettings.length !== 2) fail('expected Debug and Release LiveDrawWidget CURRENT_PROJECT_VERSION settings');
const versions = [
publicVersion,
...appConfigRuntime,
...androidVersion,
...androidRuntime,
...xcodeMarketing,
plistValue(iosInfo, 'CFBundleShortVersionString', 'iOS app Info.plist'),
plistValue(widgetInfo, 'CFBundleShortVersionString', 'widget Info.plist'),
plistValue(iosExpo, 'EXUpdatesRuntimeVersion', 'iOS Expo.plist'),
];
if (!versions.every((version) => semverPattern.test(version))) fail(`versions must be numeric SemVer: ${versions.join(', ')}`);
if (new Set(versions).size !== 1) fail(`public or OTA runtime versions disagree: ${versions.join(', ')}`);
const appBuild = plistValue(iosInfo, 'CFBundleVersion', 'iOS app Info.plist');
const widgetBuild = plistValue(widgetInfo, 'CFBundleVersion', 'widget Info.plist');
if (!/^\d+$/.test(appBuild) || !/^\d+$/.test(widgetBuild)) fail('iOS build numbers must be positive integers');
if (appBuild !== widgetBuild || widgetBuildSettings.some((build) => build !== appBuild)) {
fail(`iOS app, widget, and effective widget Xcode build numbers disagree: app ${appBuild}; widget plist ${widgetBuild}; widget settings ${widgetBuildSettings.join(', ')}`);
}
for (const permission of ['READ_MEDIA_IMAGES', 'READ_MEDIA_VIDEO']) {
const removed = new RegExp(`<uses-permission[^>]*android:name="android\\.permission\\.${permission}"[^>]*tools:node="remove"`).test(androidManifest);
if (!removed) fail(`AndroidManifest.xml must keep tools:node="remove" on ${permission} (expo prebuild regresses this)`);
}
const forbiddenPlistKeys = ['NSBonjourServices', 'NSLocalNetworkUsageDescription', 'RCTMetroPort'];
const foundDevLauncherKeys = forbiddenPlistKeys.filter((key) => iosInfo.includes(key));
if (foundDevLauncherKeys.length) {
fail(`ios/Sauci/Info.plist must not contain dev-launcher-only keys reintroduced by expo prebuild: ${foundDevLauncherKeys.join(', ')}`);
}
const tracked = isTracked ?? ((relative) => gitStatus(['ls-files', '--error-unmatch', relative], root) === 0);
if (!tracked(certificatePath)) fail(`${certificatePath} must be tracked`);
const archiveIgnore = existsSync(join(root, '.easignore')) ? '.easignore' : '.gitignore';
if (!existsSync(join(root, archiveIgnore))) fail(`missing root ${archiveIgnore} archive rules`);
const archiveRules = file(archiveIgnore).split(/\r?\n/).map((line) => line.trim());
const pemRules = archiveRules.map((line, index) => line === '*.pem' ? index : -1).filter((index) => index >= 0);
const certificateAllowlist = archiveRules.lastIndexOf(`!${certificatePath}`);
if (!pemRules.length) fail(`${archiveIgnore} must retain the private-key-safe *.pem archive rule`);
if (certificateAllowlist <= Math.max(...pemRules)) {
fail(`${archiveIgnore} must later allowlist !${certificatePath} for the public OTA certificate`);
}
if (!file(certificatePath).trim()) fail(`${certificatePath} is empty`);
return { publicVersion, iosBuild: appBuild };
}
export function validateLocalEas(environment = process.env) {
const pluginPath = environment.EAS_LOCAL_BUILD_PLUGIN_PATH;
if (!pluginPath) fail('EAS_LOCAL_BUILD_PLUGIN_PATH must name the matching local-build plugin executable bin/run');
if (!pluginPath.endsWith('/bin/run') || !existsSync(pluginPath) || !statSync(pluginPath).isFile()) {
fail('EAS_LOCAL_BUILD_PLUGIN_PATH must point to an existing executable bin/run, not a package directory');
}
if (!statSync(pluginPath).mode.toString(8).match(/[1357][0-7][0-7]$/)) fail('EAS_LOCAL_BUILD_PLUGIN_PATH bin/run is not executable');
}
export function validateFreeDisk(root = repoRoot, readStats = statfsSync) {
const stats = readStats(root);
const freeBytes = Number(stats.bavail) * Number(stats.bsize);
if (freeBytes < minimumFreeBuildBytes) {
fail(`need at least 20 GiB free before a local build; found ${(freeBytes / 1024 ** 3).toFixed(1)} GiB`);
}
}
export function main(args = process.argv.slice(2), environment = process.env) {
if (args.length > 1 || (args.length === 1 && args[0] !== '--local-eas')) {
fail('usage: node scripts/release-preflight.mjs [--local-eas]');
}
const result = validateReleaseSources();
validateFreeDisk();
if (args.includes('--local-eas')) validateLocalEas(environment);
console.log(`Mobile release source preflight passed for v${result.publicVersion}, iOS build ${result.iosBuild}`);
}
if (process.argv[1] === new URL(import.meta.url).pathname) main();
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { validateFreeDisk, validateLocalEas, validateReleaseSources } from './release-preflight.mjs';
const repoRoot = join(import.meta.dirname, '..', '..', '..');
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'sauci-release-preflight-'));
cpSync(join(repoRoot, 'apps', 'mobile'), join(root, 'apps', 'mobile'), { recursive: true });
cpSync(join(repoRoot, '.gitignore'), join(root, '.gitignore'));
return root;
}
function replace(root, relative, from, to) {
const file = join(root, relative);
writeFileSync(file, readFileSync(file, 'utf8').replace(from, to));
}
function validate(root) {
return validateReleaseSources({ root, isTracked: () => true });
}
test('accepts current checked-in mobile release sources', () => {
const root = fixture();
try {
const result = validate(root);
assert.match(result.publicVersion, /^\d+\.\d+\.\d+$/);
assert.match(result.iosBuild, /^\d+$/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('rejects a widget Xcode build setting that overrides its plist', () => {
const root = fixture();
try {
const { iosBuild } = validate(root);
const project = 'apps/mobile/ios/Sauci.xcodeproj/project.pbxproj';
const source = readFileSync(join(root, project), 'utf8');
const expected = `CURRENT_PROJECT_VERSION = ${iosBuild};`;
const replacement = `CURRENT_PROJECT_VERSION = ${Number(iosBuild) + 1};`;
assert.ok(source.includes(expected));
replace(root, project, expected, replacement);
assert.ok(readFileSync(join(root, project), 'utf8').includes(replacement));
assert.throws(() => validate(root), /iOS app, widget, and effective widget Xcode build numbers disagree/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('rejects an OTA certificate without its root archive allowlist', () => {
const root = fixture();
try {
const archiveRules = readFileSync(join(root, '.gitignore'), 'utf8');
assert.ok(archiveRules.includes('!apps/mobile/certs/certificate.pem'));
replace(root, '.gitignore', '!apps/mobile/certs/certificate.pem', '');
assert.ok(!readFileSync(join(root, '.gitignore'), 'utf8').includes('!apps/mobile/certs/certificate.pem'));
assert.throws(
() => validate(root),
/must later allowlist !apps\/mobile\/certs\/certificate\.pem/,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('rejects an Android manifest that lost the media permission removal (expo prebuild regression)', () => {
const root = fixture();
try {
const manifest = 'apps/mobile/android/app/src/main/AndroidManifest.xml';
replace(
root,
manifest,
'<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>',
'<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>',
);
assert.throws(() => validate(root), /READ_MEDIA_IMAGES \(expo prebuild regresses this\)/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('rejects an iOS Info.plist that regained dev-launcher-only keys (expo prebuild regression)', () => {
const root = fixture();
try {
const infoPlist = 'apps/mobile/ios/Sauci/Info.plist';
replace(
root,
infoPlist,
'</dict>\n</plist>',
'<key>NSBonjourServices</key>\n<array><string>_expo._tcp</string></array>\n</dict>\n</plist>',
);
assert.throws(() => validate(root), /dev-launcher-only keys reintroduced by expo prebuild: NSBonjourServices/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('requires the local EAS plugin executable rather than its package directory', () => {
const root = mkdtempSync(join(tmpdir(), 'sauci-eas-plugin-'));
const executable = join(root, 'bin-run');
try {
writeFileSync(executable, '#!/bin/sh\n');
chmodSync(executable, 0o755);
assert.throws(
() => validateLocalEas({ EAS_LOCAL_BUILD_PLUGIN_PATH: root }),
/executable bin\/run, not a package directory/,
);
const bin = join(root, 'bin');
mkdirSync(bin);
const run = join(bin, 'run');
cpSync(executable, run);
chmodSync(run, 0o755);
validateLocalEas({ EAS_LOCAL_BUILD_PLUGIN_PATH: run });
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('requires enough disk before an expensive local build', () => {
assert.throws(
() => validateFreeDisk('/fixture', () => ({ bavail: 19, bsize: 1024 ** 3 })),
/need at least 20 GiB free/,
);
validateFreeDisk('/fixture', () => ({ bavail: 20, bsize: 1024 ** 3 }));
});
@@ -1,31 +1,43 @@
import { act, renderHook } from '@testing-library/react-native'; import { act, renderHook } from '@testing-library/react-native';
import { Alert } from 'react-native'; import { Alert } from 'react-native';
import { Audio } from 'expo-av'; import { AudioModule, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio';
import { useAudioRecorder } from '@/hooks/useAudioRecorder'; import { useAudioRecorder } from '@/hooks/useAudioRecorder';
// eslint-plugin-import's namespace rule cannot statically resolve the
// `AudioRecorder` class exposed on the `AudioModule` native module instance.
// Alias it once here so the mock constructor is only dereferenced this way
// in a single, deliberately suppressed spot.
// eslint-disable-next-line import/namespace
const AudioRecorderMock = AudioModule.AudioRecorder as jest.Mock;
const makeRecording = (overrides: Record<string, unknown> = {}) => ({ const makeRecording = (overrides: Record<string, unknown> = {}) => ({
getStatusAsync: jest.fn(async () => ({ isRecording: true, durationMillis: 2400 })), getStatus: jest.fn(() => ({ isRecording: true, durationMillis: 2400, canRecord: true, url: null })),
stopAndUnloadAsync: jest.fn(async () => undefined), prepareToRecordAsync: jest.fn(async () => undefined),
getURI: jest.fn(() => 'file://recording.m4a'), record: jest.fn(),
setOnRecordingStatusUpdate: jest.fn(), stop: jest.fn(async () => undefined),
setProgressUpdateInterval: jest.fn(), uri: 'file://recording.m4a',
...overrides, ...overrides,
}); });
describe('useAudioRecorder', () => { describe('useAudioRecorder', () => {
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks(); jest.clearAllMocks();
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
jest.spyOn(console, 'error').mockImplementation(() => undefined); jest.spyOn(console, 'error').mockImplementation(() => undefined);
(Audio.requestPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' }); (requestRecordingPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
(Audio.setAudioModeAsync as jest.Mock).mockResolvedValue(undefined); (setAudioModeAsync as jest.Mock).mockResolvedValue(undefined);
}); });
afterEach(() => jest.restoreAllMocks()); afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
jest.restoreAllMocks();
});
it('alerts and refuses to start without microphone permission', async () => { it('alerts and refuses to start without microphone permission', async () => {
(Audio.requestPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' }); (requestRecordingPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
const { result } = renderHook(() => useAudioRecorder()); const { result } = renderHook(() => useAudioRecorder());
let started = true; let started = true;
@@ -36,21 +48,22 @@ describe('useAudioRecorder', () => {
'Microphone Permission', 'Microphone Permission',
'Please allow microphone access to record audio.' 'Please allow microphone access to record audio.'
); );
expect(Audio.Recording.createAsync).not.toHaveBeenCalled(); expect(AudioRecorderMock).not.toHaveBeenCalled();
}); });
it('starts, reports duration, and returns the completed recording', async () => { it('starts, reports duration, and returns the completed recording', async () => {
const recording = makeRecording(); const recording = makeRecording();
(Audio.Recording.createAsync as jest.Mock).mockResolvedValue({ recording }); AudioRecorderMock.mockImplementation(() => recording);
const onComplete = jest.fn(); const onComplete = jest.fn();
const { result } = renderHook(() => useAudioRecorder({ onRecordingComplete: onComplete })); const { result } = renderHook(() => useAudioRecorder({ onRecordingComplete: onComplete }));
await act(async () => { expect(await result.current.startRecording()).toBe(true); }); await act(async () => { expect(await result.current.startRecording()).toBe(true); });
expect(result.current.state).toBe('recording'); expect(result.current.state).toBe('recording');
expect(recording.setProgressUpdateInterval).toHaveBeenCalledWith(100); expect(recording.prepareToRecordAsync).toHaveBeenCalled();
expect(recording.record).toHaveBeenCalled();
const statusCallback = recording.setOnRecordingStatusUpdate.mock.calls[0][0]; // Duration is reported by polling the recorder status, not by a push event.
act(() => statusCallback({ isRecording: true, durationMillis: 2400 })); await act(async () => { jest.advanceTimersByTime(100); });
expect(result.current.durationSeconds).toBe(2); expect(result.current.durationSeconds).toBe(2);
let completed: unknown; let completed: unknown;
@@ -66,50 +79,55 @@ describe('useAudioRecorder', () => {
it('auto-stops when the configured duration is reached', async () => { it('auto-stops when the configured duration is reached', async () => {
const recording = makeRecording({ const recording = makeRecording({
getStatusAsync: jest.fn(async () => ({ isRecording: true, durationMillis: 1000 })), getStatus: jest.fn(() => ({ isRecording: true, durationMillis: 1000, canRecord: true, url: null })),
}); });
(Audio.Recording.createAsync as jest.Mock).mockResolvedValue({ recording }); AudioRecorderMock.mockImplementation(() => recording);
const onComplete = jest.fn(); const onComplete = jest.fn();
const { result } = renderHook(() => useAudioRecorder({ maxDurationSeconds: 1, onRecordingComplete: onComplete })); const { result } = renderHook(() => useAudioRecorder({ maxDurationSeconds: 1, onRecordingComplete: onComplete }));
await act(async () => { await result.current.startRecording(); }); await act(async () => { await result.current.startRecording(); });
const statusCallback = recording.setOnRecordingStatusUpdate.mock.calls[0][0]; await act(async () => { jest.advanceTimersByTime(100); });
await act(async () => { statusCallback({ isRecording: true, durationMillis: 1000 }); }); // Flush the microtasks queued by the auto-stop call.
await act(async () => { await Promise.resolve(); });
expect(recording.stopAndUnloadAsync).toHaveBeenCalled(); expect(recording.stop).toHaveBeenCalled();
expect(onComplete).toHaveBeenCalledWith('file://recording.m4a', 1); expect(onComplete).toHaveBeenCalledWith('file://recording.m4a', 1);
}); });
it('resets an active recording and cleans it up on unmount', async () => { it('resets an active recording and cleans it up on unmount', async () => {
const first = makeRecording(); const first = makeRecording();
const second = makeRecording(); const second = makeRecording();
(Audio.Recording.createAsync as jest.Mock) AudioRecorderMock
.mockResolvedValueOnce({ recording: first }) .mockImplementationOnce(() => first)
.mockResolvedValueOnce({ recording: second }); .mockImplementationOnce(() => second);
const view = renderHook(() => useAudioRecorder()); const view = renderHook(() => useAudioRecorder());
await act(async () => { await view.result.current.startRecording(); }); await act(async () => { await view.result.current.startRecording(); });
act(() => view.result.current.resetRecording()); act(() => view.result.current.resetRecording());
expect(first.stopAndUnloadAsync).toHaveBeenCalled(); expect(first.stop).toHaveBeenCalled();
expect(view.result.current.state).toBe('idle'); expect(view.result.current.state).toBe('idle');
await act(async () => { await view.result.current.startRecording(); }); await act(async () => { await view.result.current.startRecording(); });
view.unmount(); view.unmount();
expect(second.stopAndUnloadAsync).toHaveBeenCalled(); expect(second.stop).toHaveBeenCalled();
expect(Audio.setAudioModeAsync).toHaveBeenLastCalledWith({ expect(setAudioModeAsync).toHaveBeenLastCalledWith({
allowsRecordingIOS: false, allowsRecording: false,
playsInSilentModeIOS: true, playsInSilentMode: true,
}); });
}); });
it('recovers when starting or stopping throws', async () => { it('recovers when starting or stopping throws', async () => {
(Audio.Recording.createAsync as jest.Mock).mockRejectedValueOnce(new Error('native unavailable')); AudioRecorderMock.mockImplementationOnce(() => {
throw new Error('native unavailable');
});
const first = renderHook(() => useAudioRecorder()); const first = renderHook(() => useAudioRecorder());
await act(async () => { expect(await first.result.current.startRecording()).toBe(false); }); await act(async () => { expect(await first.result.current.startRecording()).toBe(false); });
expect(first.result.current.state).toBe('idle'); expect(first.result.current.state).toBe('idle');
const recording = makeRecording({ getStatusAsync: jest.fn(async () => { throw new Error('lost'); }) }); const recording = makeRecording({
(Audio.Recording.createAsync as jest.Mock).mockResolvedValueOnce({ recording }); getStatus: jest.fn(() => { throw new Error('lost'); }),
});
AudioRecorderMock.mockImplementationOnce(() => recording);
const second = renderHook(() => useAudioRecorder()); const second = renderHook(() => useAudioRecorder());
await act(async () => { await second.result.current.startRecording(); }); await act(async () => { await second.result.current.startRecording(); });
await act(async () => { expect(await second.result.current.stopRecording()).toBeNull(); }); await act(async () => { expect(await second.result.current.stopRecording()).toBeNull(); });
@@ -1,14 +1,19 @@
import { act, renderHook } from '@testing-library/react-native'; import { act, renderHook } from '@testing-library/react-native';
import { ActionSheetIOS, Alert, Platform } from 'react-native'; import { ActionSheetIOS, Alert, Linking, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { useAvatarPicker } from '@/hooks/useAvatarPicker'; import { useAvatarPicker } from '@/hooks/useAvatarPicker';
jest.mock('@/lib/mediaApi', () => ({
uploadMedia: jest.fn(),
}));
jest.mock('expo-file-system/legacy', () => ({ jest.mock('expo-file-system/legacy', () => ({
getInfoAsync: jest.fn(), getInfoAsync: jest.fn(),
})); }));
jest.mock('expo-image-picker', () => ({ jest.mock('expo-image-picker', () => ({
getCameraPermissionsAsync: jest.fn(),
requestCameraPermissionsAsync: jest.fn(), requestCameraPermissionsAsync: jest.fn(),
requestMediaLibraryPermissionsAsync: jest.fn(), requestMediaLibraryPermissionsAsync: jest.fn(),
launchCameraAsync: jest.fn(), launchCameraAsync: jest.fn(),
@@ -23,7 +28,10 @@ describe('useAvatarPicker', () => {
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' }); Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' });
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined); jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined);
jest.spyOn(Linking, 'openSettings').mockResolvedValue(undefined);
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] }); (ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] });
(ImagePicker.getCameraPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'undetermined', canAskAgain: true });
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
(FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true }); (FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true });
}); });
@@ -64,4 +72,41 @@ describe('useAvatarPicker', () => {
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1); expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1);
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledTimes(1); expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledTimes(1);
}); });
it('does not show a custom alert immediately after a camera permission denial', async () => {
const { result } = renderHook(() => useAvatarPicker());
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied', canAskAgain: false });
act(() => result.current.showPicker());
const takePhoto = (Alert.alert as jest.Mock).mock.calls[0][2].find(
(option: { text: string }) => option.text === 'Take Photo'
);
await act(async () => takePhoto.onPress());
expect(Alert.alert).toHaveBeenCalledTimes(1);
expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled();
});
it('offers Settings only for a later camera attempt that iOS cannot prompt again', async () => {
(ImagePicker.getCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied', canAskAgain: false });
const { result } = renderHook(() => useAvatarPicker());
act(() => result.current.showPicker());
const takePhoto = (Alert.alert as jest.Mock).mock.calls[0][2].find(
(option: { text: string }) => option.text === 'Take Photo'
);
await act(async () => takePhoto.onPress());
const settingsAlert = (Alert.alert as jest.Mock).mock.calls[1];
expect(settingsAlert[0]).toBe('Camera Access');
expect(settingsAlert[2]).toEqual([
{ text: 'Not Now', style: 'cancel' },
expect.objectContaining({ text: 'Open Settings' }),
]);
await act(async () => settingsAlert[2][1].onPress());
expect(Linking.openSettings).toHaveBeenCalledTimes(1);
expect(ImagePicker.requestCameraPermissionsAsync).not.toHaveBeenCalled();
});
}); });
@@ -1,4 +1,4 @@
import { ApiError, apiRequest } from "@/lib/apiClient"; import { ApiError, apiRequest, apiRequestWithAccessToken } from "@/lib/apiClient";
import { authClient } from "@/lib/authClient"; import { authClient } from "@/lib/authClient";
function response(status: number, body?: unknown) { function response(status: number, body?: unknown) {
@@ -40,6 +40,17 @@ describe("apiClient", () => {
expect(fetchMock).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled();
}); });
it("uses an explicitly captured bearer without reading a mutable auth session", async () => {
fetchMock.mockResolvedValue(response(200, { profile: { id: "user-1" } }));
(authClient.auth.getSession as jest.Mock).mockClear();
await apiRequestWithAccessToken("/v1/me", "captured-token");
const headers = fetchMock.mock.calls[0][1].headers as Headers;
expect(headers.get("Authorization")).toBe("Bearer captured-token");
expect(authClient.auth.getSession).not.toHaveBeenCalled();
});
it("preserves API status and error details", async () => { it("preserves API status and error details", async () => {
fetchMock.mockResolvedValue(response(403, { fetchMock.mockResolvedValue(response(403, {
error: { message: "Couple access denied", code: "FORBIDDEN" }, error: { message: "Couple access denied", code: "FORBIDDEN" },
@@ -0,0 +1,21 @@
import { formatAppleProfileName } from '@/lib/appleProfileName';
describe('formatAppleProfileName', () => {
it('combines Apple name parts into the profile display name', () => {
expect(formatAppleProfileName({
givenName: ' Ada ',
middleName: ' Lovelace',
familyName: ' Byron ',
})).toBe('Ada Lovelace Byron');
});
it('returns null when Apple has not supplied a usable name', () => {
expect(formatAppleProfileName({ givenName: ' \n\t ' })).toBeNull();
expect(formatAppleProfileName(null)).toBeNull();
});
it('removes control characters and stays within the profile contract limit', () => {
expect(formatAppleProfileName({ givenName: 'Ada\u0000 Lovelace' })).toBe('Ada Lovelace');
expect(formatAppleProfileName({ givenName: 'a'.repeat(101) })).toHaveLength(100);
});
});
@@ -0,0 +1,45 @@
import { persistAppleProfileName } from '@/lib/appleProfileNamePersistence';
describe('persistAppleProfileName', () => {
const session = { userId: 'apple-user', accessToken: 'apple-token' };
const getProfile = jest.fn();
const updateProfileName = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('writes a supplied name only after a matching blank profile is read with the captured bearer', async () => {
getProfile.mockResolvedValue({ profile: { id: 'apple-user', name: null } });
updateProfileName.mockResolvedValue({ updated: true });
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
.resolves.toBe('updated');
expect(getProfile).toHaveBeenCalledWith('apple-token');
expect(updateProfileName).toHaveBeenCalledWith('apple-token', 'Ada Lovelace');
});
it('preserves a returning user nickname', async () => {
getProfile.mockResolvedValue({ profile: { id: 'apple-user', name: 'Ada B' } });
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
.resolves.toBe('existing');
expect(updateProfileName).not.toHaveBeenCalled();
});
it('fails closed on a subject mismatch before any profile write', async () => {
getProfile.mockResolvedValue({ profile: { id: 'different-user', name: null } });
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
.rejects.toThrow('subject did not match');
expect(updateProfileName).not.toHaveBeenCalled();
});
it('propagates API failure so the caller can clear its pending onboarding name', async () => {
getProfile.mockRejectedValue(new Error('offline'));
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
.rejects.toThrow('offline');
expect(updateProfileName).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,45 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";
import {
hasSeenMatchesTutorial,
markMatchesTutorialSeen,
resetMatchesTutorial,
} from "@/lib/matchesTutorialSeen";
jest.mock("expo-secure-store", () => ({
getItemAsync: jest.fn(),
}));
describe("matchesTutorialSeen", () => {
beforeEach(async () => {
await AsyncStorage.clear();
jest.mocked(SecureStore.getItemAsync).mockReset();
jest.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
});
it("persists a dismissal for the signed-in account", async () => {
await markMatchesTutorialSeen("user-1");
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(true);
});
it("does not dismiss the tutorial for another account on the same device", async () => {
await markMatchesTutorialSeen("user-1");
await expect(hasSeenMatchesTutorial("user-2")).resolves.toBe(false);
});
it("migrates an existing legacy dismissal into durable account storage", async () => {
jest.mocked(SecureStore.getItemAsync).mockResolvedValue("true");
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(true);
await expect(AsyncStorage.getItem("matches_tutorial_seen_user-1")).resolves.toBe("true");
});
it("lets the development reset show the tutorial for the current account again", async () => {
await markMatchesTutorialSeen("user-1");
await resetMatchesTutorial("user-1");
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(false);
});
});
@@ -0,0 +1,113 @@
jest.mock('expo-observe', () => ({
Observe: {
logEvent: jest.fn(),
},
}));
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { Observe } = require('expo-observe');
const logEventMock = Observe.logEvent as jest.Mock;
type ErrorHandler = (error: unknown, isFatal: boolean) => void;
function withFreshObserveModule<T>(run: (mod: typeof import('@/lib/observe')) => T): T {
let result!: T;
jest.isolateModules(() => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require('@/lib/observe') as typeof import('@/lib/observe');
result = run(mod);
});
return result;
}
function installFakeErrorUtils(previousHandler: jest.Mock) {
let installedHandler: ErrorHandler = () => undefined;
const globalWithErrorUtils = global as unknown as {
ErrorUtils: { getGlobalHandler: () => unknown; setGlobalHandler: (fn: unknown) => void };
};
globalWithErrorUtils.ErrorUtils = {
getGlobalHandler: () => previousHandler,
setGlobalHandler: (fn) => {
installedHandler = fn as ErrorHandler;
},
};
return () => installedHandler;
}
describe('observe', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('logs app_started only once per module instance', () => {
withFreshObserveModule(({ logAppStarted }) => {
logAppStarted();
logAppStarted();
});
expect(logEventMock).toHaveBeenCalledTimes(1);
expect(logEventMock).toHaveBeenCalledWith('app_started');
});
it('does not throw when Observe.logEvent fails', () => {
logEventMock.mockImplementationOnce(() => {
throw new Error('native module unavailable');
});
withFreshObserveModule(({ logAppStarted }) => {
expect(() => logAppStarted()).not.toThrow();
});
});
it('wraps the previous ErrorUtils handler, logging xprem_js_crash and delegating', () => {
const previousHandler = jest.fn();
const getInstalledHandler = installFakeErrorUtils(previousHandler);
withFreshObserveModule(({ installObserveGlobalErrorHandler }) => {
installObserveGlobalErrorHandler();
});
const error = new Error('boom');
getInstalledHandler()(error, true);
expect(logEventMock).toHaveBeenCalledWith(
'xprem_js_crash',
expect.objectContaining({
severity: 'fatal',
body: 'boom',
})
);
expect(previousHandler).toHaveBeenCalledWith(error, true);
});
it('does not log xprem_js_crash for non-fatal errors but still delegates', () => {
const previousHandler = jest.fn();
const getInstalledHandler = installFakeErrorUtils(previousHandler);
withFreshObserveModule(({ installObserveGlobalErrorHandler }) => {
installObserveGlobalErrorHandler();
});
const error = new Error('handled');
getInstalledHandler()(error, false);
expect(logEventMock).not.toHaveBeenCalled();
expect(previousHandler).toHaveBeenCalledWith(error, false);
});
it('still calls the previous handler when Observe.logEvent throws', () => {
const previousHandler = jest.fn();
const getInstalledHandler = installFakeErrorUtils(previousHandler);
logEventMock.mockImplementationOnce(() => {
throw new Error('native module unavailable');
});
withFreshObserveModule(({ installObserveGlobalErrorHandler }) => {
installObserveGlobalErrorHandler();
});
getInstalledHandler()(new Error('boom'), true);
expect(previousHandler).toHaveBeenCalledWith(expect.any(Error), true);
});
});
@@ -0,0 +1,28 @@
import { activeOnboardingStage, initialOnboardingStage, previousOnboardingStage } from '@/lib/onboardingStages';
describe('initialOnboardingStage', () => {
it('starts at gender when a profile already has an Apple-supplied name', () => {
expect(initialOnboardingStage('Ada Lovelace')).toBe('gender');
});
it('starts at name as a fallback when no name is available', () => {
expect(initialOnboardingStage(' ')).toBe('name');
expect(initialOnboardingStage(null)).toBe('name');
});
it('does not provide a back destination before the first reachable stage', () => {
expect(previousOnboardingStage('name', false)).toBeNull();
expect(previousOnboardingStage('gender', true)).toBeNull();
});
it('allows backwards navigation only through reachable stages', () => {
expect(previousOnboardingStage('gender', false)).toBe('name');
expect(previousOnboardingStage('purpose', true)).toBe('gender');
expect(previousOnboardingStage('notifications', false)).toBe('purpose');
});
it('keeps first-auth onboarding out of the name screen only while its subject-keyed name is pending', () => {
expect(activeOnboardingStage('name', null, 'Ada Lovelace')).toBe('gender');
expect(activeOnboardingStage('name', null, undefined)).toBe('name');
});
});
@@ -1,4 +1,4 @@
import { apiClient } from '../../lib/apiClient'; import { apiClient, apiRequestWithAccessToken } from '../../lib/apiClient';
import { profileSettingsApi } from '../../lib/profileSettingsApi'; import { profileSettingsApi } from '../../lib/profileSettingsApi';
jest.mock('../../lib/apiClient', () => ({ jest.mock('../../lib/apiClient', () => ({
@@ -7,6 +7,7 @@ jest.mock('../../lib/apiClient', () => ({
post: jest.fn(), post: jest.fn(),
patch: jest.fn(), patch: jest.fn(),
}, },
apiRequestWithAccessToken: jest.fn(),
})); }));
describe('profileSettingsApi', () => { describe('profileSettingsApi', () => {
@@ -27,4 +28,14 @@ describe('profileSettingsApi', () => {
type: 'bug', title: 'Title', description: 'Details', type: 'bug', title: 'Title', description: 'Details',
}); });
}); });
it('can bind a profile update to a captured authentication bearer', async () => {
await profileSettingsApi.updateProfileWithAccessToken('captured-token', { name: 'Ada' });
expect(apiRequestWithAccessToken).toHaveBeenCalledWith(
'/v1/me/profile',
'captured-token',
{ method: 'PATCH', body: { name: 'Ada' } },
);
});
}); });
@@ -30,10 +30,14 @@ const profile = {
describe("authStore", () => { describe("authStore", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
(authClient.auth.getSession as jest.Mock).mockResolvedValue({
data: { session: { user: { id: "me" }, access_token: "token-me" } },
});
// fetchUser always fetches couple state now, since it also carries the // fetchUser always fetches couple state now, since it also carries the
// sealed answer count for a user who has no couple yet. Individual tests // sealed answer count for a user who has no couple yet. Individual tests
// override this when they care about the couple/partner shape. // override this when they care about the couple/partner shape.
jest.spyOn(coupleApi, "getState").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 }); jest.spyOn(coupleApi, "getState").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
jest.spyOn(coupleApi, "getStateWithAccessToken").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
useAuthStore.setState({ useAuthStore.setState({
user: null, user: null,
couple: null, couple: null,
@@ -42,6 +46,7 @@ describe("authStore", () => {
isLoading: true, isLoading: true,
isAuthenticated: false, isAuthenticated: false,
isAnonymous: false, isAnonymous: false,
pendingAppleDisplayName: null,
} as any); } as any);
useMatchStore.setState({ matches: [{ id: "m1" }], newMatchesCount: 1 } as any); useMatchStore.setState({ matches: [{ id: "m1" }], newMatchesCount: 1 } as any);
usePacksStore.setState({ enabledPackIds: ["p1"] } as any); usePacksStore.setState({ enabledPackIds: ["p1"] } as any);
@@ -64,13 +69,13 @@ describe("authStore", () => {
it("bootstraps and reads the profile through GET /v1/me", async () => { it("bootstraps and reads the profile through GET /v1/me", async () => {
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({ (authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: "me", is_anonymous: true } } }, data: { session: { user: { id: "me", is_anonymous: true }, access_token: "token-me" } },
}); });
const getSpy = jest.spyOn(apiClient, "get").mockResolvedValueOnce({ profile }); const getSpy = jest.spyOn(apiClient, "getWithAccessToken").mockResolvedValueOnce({ profile });
await useAuthStore.getState().fetchUser(); await useAuthStore.getState().fetchUser();
expect(getSpy).toHaveBeenCalledWith("/v1/me"); expect(getSpy).toHaveBeenCalledWith("/v1/me", "token-me");
expect(useAuthStore.getState()).toMatchObject({ expect(useAuthStore.getState()).toMatchObject({
user: profile, user: profile,
isAuthenticated: true, isAuthenticated: true,
@@ -114,9 +119,9 @@ describe("authStore", () => {
it("clears user-scoped stores and hosted auth when the API rejects the session", async () => { it("clears user-scoped stores and hosted auth when the API rejects the session", async () => {
useAuthStore.setState({ sealedCount: 3 } as any); useAuthStore.setState({ sealedCount: 3 } as any);
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({ (authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: "me" } } }, data: { session: { user: { id: "me" }, access_token: "token-me" } },
}); });
jest.spyOn(apiClient, "get").mockRejectedValueOnce(new ApiError("Unauthorized", 401)); jest.spyOn(apiClient, "getWithAccessToken").mockRejectedValueOnce(new ApiError("Unauthorized", 401));
(authClient.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null }); (authClient.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null });
await useAuthStore.getState().fetchUser(); await useAuthStore.getState().fetchUser();
@@ -135,6 +140,123 @@ describe("authStore", () => {
expect(authClient.auth.signOut).toHaveBeenCalled(); expect(authClient.auth.signOut).toHaveBeenCalled();
}); });
it("does not let an older session response overwrite a newer profile", async () => {
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
let resolveA!: (value: { profile: Profile }) => void;
let resolveB!: (value: { profile: Profile }) => void;
const getSpy = jest.spyOn(apiClient, "getWithAccessToken")
.mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve; }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveB = resolve; }));
const firstFetch = useAuthStore.getState().fetchUser();
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
const secondFetch = useAuthStore.getState().fetchUser();
await Promise.resolve();
await Promise.resolve();
resolveB({ profile: { ...profile, id: "user-b", name: "Newer Name" } as Profile });
await secondFetch;
resolveA({ profile: { ...profile, id: "user-a", name: "Older Name" } as Profile });
await firstFetch;
expect(getSpy).toHaveBeenNthCalledWith(1, "/v1/me", "token-a");
expect(getSpy).toHaveBeenNthCalledWith(2, "/v1/me", "token-b");
expect(useAuthStore.getState()).toMatchObject({
user: expect.objectContaining({ id: "user-b", name: "Newer Name" }),
isAuthenticated: true,
isLoading: false,
});
});
it("does not let an older couple response overwrite a newer session", async () => {
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
jest.spyOn(apiClient, "getWithAccessToken")
.mockResolvedValueOnce({ profile: { ...profile, id: "user-a", name: "Older Name" } })
.mockResolvedValueOnce({ profile: { ...profile, id: "user-b", name: "Newer Name" } });
let resolveCoupleA!: (value: { couple: null; partner: null; sealed_count: number }) => void;
jest.spyOn(coupleApi, "getStateWithAccessToken")
.mockImplementationOnce(() => new Promise((resolve) => { resolveCoupleA = resolve; }))
.mockResolvedValueOnce({ couple: null, partner: null, sealed_count: 7 });
const firstFetch = useAuthStore.getState().fetchUser();
for (let attempt = 0; attempt < 10 && !resolveCoupleA; attempt += 1) {
await Promise.resolve();
}
expect(resolveCoupleA).toBeDefined();
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
const secondFetch = useAuthStore.getState().fetchUser();
await secondFetch;
resolveCoupleA({ couple: null, partner: null, sealed_count: 1 });
await firstFetch;
expect(useAuthStore.getState()).toMatchObject({
user: expect.objectContaining({ id: "user-b", name: "Newer Name" }),
sealedCount: 7,
isLoading: false,
});
});
it("rejects a mismatched API profile without signing out the active session", async () => {
useAuthStore.setState({
user: profile,
couple: { id: "prior-couple", invite_code: "PRIOR123", created_at: profile.created_at },
partner: { ...profile, id: "prior-partner", name: "Prior Partner" },
sealedCount: 4,
isAuthenticated: true,
isAnonymous: true,
pendingAppleDisplayName: { userId: "me", name: "Prior Name" },
} as any);
jest.spyOn(apiClient, "getWithAccessToken").mockResolvedValueOnce({
profile: { ...profile, id: "different-user", name: "Wrong Subject" },
});
await useAuthStore.getState().fetchUser();
expect(useAuthStore.getState()).toMatchObject({
user: null,
couple: null,
partner: null,
sealedCount: 0,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
pendingAppleDisplayName: null,
});
expect(useMatchStore.getState().matches).toEqual([]);
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
expect(useMessageStore.getState().unreadCount).toBe(0);
expect(useSubscriptionStore.getState().subscription).toMatchObject({ isProUser: false });
expect(authClient.auth.signOut).not.toHaveBeenCalled();
expect(coupleApi.getStateWithAccessToken).not.toHaveBeenCalled();
});
it("does not sign out a newer session when an older request is rejected", async () => {
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
let rejectA!: (error: Error) => void;
const getSpy = jest.spyOn(apiClient, "getWithAccessToken")
.mockImplementationOnce(() => new Promise((_, reject) => { rejectA = reject; }))
.mockResolvedValueOnce({ profile: { ...profile, id: "user-b", name: "Newer Name" } });
const firstFetch = useAuthStore.getState().fetchUser();
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
const secondFetch = useAuthStore.getState().fetchUser();
await Promise.resolve();
await Promise.resolve();
await secondFetch;
rejectA(new ApiError("Unauthorized", 401));
await firstFetch;
expect(getSpy).toHaveBeenCalledTimes(2);
expect(authClient.auth.signOut).not.toHaveBeenCalled();
expect(useAuthStore.getState().user).toMatchObject({ id: "user-b", name: "Newer Name" });
});
it("clears sealed answers during an explicit sign-out", async () => { it("clears sealed answers during an explicit sign-out", async () => {
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any); useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
@@ -158,4 +280,14 @@ describe("authStore", () => {
isAuthenticated: false, isAuthenticated: false,
}); });
}); });
it('keeps a pending Apple name scoped to its original subject', () => {
useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' });
useAuthStore.getState().clearPendingAppleDisplayName('other-user');
expect(useAuthStore.getState().pendingAppleDisplayName).toEqual({ userId: 'apple-user', name: 'Ada Lovelace' });
useAuthStore.getState().clearPendingAppleDisplayName('apple-user');
expect(useAuthStore.getState().pendingAppleDisplayName).toBeNull();
});
}); });
@@ -310,7 +310,7 @@ const styles = StyleSheet.create({
color: colors.textTertiary, color: colors.textTertiary,
}, },
lockOverlay: { lockOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0, 0, 0, 0.5)', backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
+1 -1
View File
@@ -307,7 +307,7 @@ export const MatchConfetti: React.FC<Props> = ({ visible, onAnimationComplete })
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
zIndex: 1000, zIndex: 1000,
}, },
particle: { particle: {
@@ -344,7 +344,7 @@ export const MatchNotificationModal: React.FC<Props> = ({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlay: { overlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
backgroundColor: 'rgba(0, 0, 0, 0.75)', backgroundColor: 'rgba(0, 0, 0, 0.75)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
@@ -377,7 +377,7 @@ const styles = StyleSheet.create({
borderColor: colors.border, borderColor: colors.border,
}, },
flatBackground: { flatBackground: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFill,
backgroundColor: colors.backgroundLight, backgroundColor: colors.backgroundLight,
}, },
// Removed blur/gradient styles // Removed blur/gradient styles
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more