Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63b8808a2a |
No files matched your search
@@ -3,8 +3,8 @@ id: 2026-08-30-ios-extension-version-verification
|
||||
date: 2026-08-30
|
||||
status: promoted
|
||||
scope: iOS release artifact verification
|
||||
evidence: v1.0.6 exported IPA inspection and reproduced v1.0.7 Xcode build-setting mismatch
|
||||
destination: apps/mobile/scripts/release-preflight.mjs and .codex/skills/release-mobile/SKILL.md
|
||||
evidence: build 45 Xcode archive warning plus direct PlistBuddy inspection of the exported IPA
|
||||
destination: .codex/skills/release-mobile/SKILL.md
|
||||
review_after: 2027-02-28
|
||||
---
|
||||
|
||||
@@ -21,23 +21,19 @@ and every packaged extension `Info.plist` with `PlistBuddy`.
|
||||
|
||||
## Cause
|
||||
|
||||
The v1.0.6 archive warning described an intermediate state and was not
|
||||
authoritative for that exported package. The later v1.0.7 reproduction found a
|
||||
separate real hazard: `CURRENT_PROJECT_VERSION` in both LiveDrawWidget
|
||||
configurations can override the widget plist and package an older build number.
|
||||
The archive warning described an intermediate state and was not authoritative
|
||||
for the exported package. Direct IPA inspection showed both the app and
|
||||
`LiveDrawWidget.appex` at version 1.0.6 build 45.
|
||||
|
||||
## Remediation
|
||||
|
||||
Before every iOS release, require public-version parity across app config, both
|
||||
plists, and Xcode marketing settings. Require the widget Debug and Release
|
||||
`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.
|
||||
For every iOS release, inspect version and build values in 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
|
||||
|
||||
`PlistBuddy` read `CFBundleShortVersionString` 1.0.6 and `CFBundleVersion` 45 from
|
||||
both v1.0.6 packaged plists. The v1.0.7 build later proved a stale widget Xcode
|
||||
setting can survive plist-only review. `release-preflight.mjs` and its node test
|
||||
now reject that mismatch before EAS allocation; exported IPA inspection remains
|
||||
the final authority.
|
||||
both packaged plists. `codesign --verify --deep --strict` passed for the app, and
|
||||
the verified IPA SHA-256 was
|
||||
`9d4779bddac4647911a3a38e4ee37cf794c2b8691e0746a041b629c62b308e7f`.
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
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`.
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
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,328 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"project": "Sauci Private Mutual Discovery, OTA-first delivery plan",
|
||||
"overview": "Evolve Sauci into a private mutual-discovery journey for adult couples: choose a mood and private shared comfort ceiling, answer a short set privately, reveal only mutual interest, then choose an optional next action. Deliver independent client slices through Expo OTA where the installed binary is compatible, while keeping session privacy, consent state, catalogue safety, API, data, and operational changes as separately gated work.",
|
||||
"goals": [
|
||||
"Make the first useful experience a short, mood-led Quick Spark session rather than an open-ended question feed.",
|
||||
"Reveal only mutual interest or both-partner responses and never reveal an unmatched preference or which partner selected a lower comfort ceiling.",
|
||||
"Let a mutual match lead to an optional talk, save, or shared action without turning either partner into an assigned recipient.",
|
||||
"Make defer, decline, a softer alternative, and withdrawal explicit, reversible where appropriate, and free of penalties.",
|
||||
"Keep developer-authored content non-graphic, reviewed, and eligible before it can reach a customer.",
|
||||
"Maximize JavaScript and bundled-asset work that can ship through OTA after live compatibility is proven."
|
||||
],
|
||||
"nonGoals": [
|
||||
"No production deployment, OTA publish, store submission, migration execution, catalogue activation, or provider write is authorized by this PRD.",
|
||||
"No explicit sexual instructions, content intended for sexual gratification, or use of UGC as a policy workaround.",
|
||||
"No clinical relationship-repair service, coercion intervention, solo experience, points economy, or mandatory task assignment.",
|
||||
"No disclosure of individual answers, individual comfort selections, intimate content, or prompt text through analytics or push notification previews.",
|
||||
"No assumption that the legacy Supabase recommendation-function documentation describes the current standalone API implementation."
|
||||
],
|
||||
"successMetrics": [
|
||||
"Measure only aggregate privacy-safe events: session starts, two-partner completion, mutual matches, optional talk/save/action selections, skip/defer/decline/withdraw/report/block, and 7/28-day repeat sessions.",
|
||||
"No analytics event contains question text, response text, audio, photo, individual answer, individual ceiling, or intimate match detail.",
|
||||
"A paired couple can complete a reviewed Quick Spark session and see only its allowed shared outcome.",
|
||||
"A user can stop, defer, decline, or withdraw without losing access or creating a partner-visible attribution of their private reason.",
|
||||
"Every OTA-labelled story is released only after its platform/runtime canary and final native-surface review pass."
|
||||
],
|
||||
"openQuestions": [
|
||||
"Which reviewed mood taxonomy and initial prompt set best fit the product positioning without crossing the cross-platform content ceiling?",
|
||||
"Should saved mutual interests expire automatically, be archived manually, or both?",
|
||||
"What exact participant-visible wording preserves a withdrawal without falsely implying that a partner declined?",
|
||||
"What age assurance, reporting, blocking, moderation ownership, rate limits, and incident process are effective in production for all relevant UGC surfaces?",
|
||||
"Does the public Android build embed the same XPREM app id, channel headers, certificate, and runtime 1.0.8 configuration as the inspected source?"
|
||||
],
|
||||
"stack": {
|
||||
"framework": "Expo React Native with Expo Router and TypeScript",
|
||||
"mobileDelivery": "Self-hosted XPREM implementing Expo Updates; iOS public source runtime is 1.0.8 and commit 3659cc8 records iOS build 49, but installed-build compatibility requires a live canary",
|
||||
"backend": "Standalone Node HTTP API with PostgreSQL and Drizzle migrations",
|
||||
"admin": "React/Vite content administration UI",
|
||||
"auth": "Hosted Supabase Auth only; product data remains in the standalone API database",
|
||||
"analytics": "Existing privacy-safe telemetry conventions, expanded only after event-schema review"
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
"path": "/(app)/swipe",
|
||||
"name": "Quick Spark entry and session",
|
||||
"purpose": "Mood-led private short session, reusing the existing swipe feature while a dedicated route is evaluated."
|
||||
},
|
||||
{
|
||||
"path": "/(app)/matches",
|
||||
"name": "Mutual discovery outcomes",
|
||||
"purpose": "Show a mutual result and route to existing conversation, saving, or a future shared-action flow."
|
||||
},
|
||||
{
|
||||
"path": "/(app)/dares",
|
||||
"name": "Existing dares",
|
||||
"purpose": "Remain a separate sender-to-recipient challenge model; it must not be relabelled as a shared action."
|
||||
},
|
||||
{
|
||||
"path": "/admin/questions and /admin/packs",
|
||||
"name": "Question catalogue authoring",
|
||||
"purpose": "Author, review, and activate only content whose enforced eligibility contract is proven."
|
||||
}
|
||||
],
|
||||
"uiNotes": [
|
||||
"Use the existing Swipe/Match feature colour, rose-to-purple gradient, locked flame and heart icons, dark solid surfaces, and high-contrast accessible controls.",
|
||||
"Quick Spark should feel finite and calm: mood selection, a short 5-8 prompt progress state, private-answer reassurance, and a restrained completion state.",
|
||||
"Comfort selection must describe a shared ceiling, not a score to compare. Never render the partner's value, a lower-value hint, or copy that attributes the ceiling to either person.",
|
||||
"Outcome actions must be equal-weight choices. Talk opens the existing match conversation; Save is not shown until it has durable server semantics; shared action is not shown until its domain contract exists.",
|
||||
"Safety actions must be clear, non-judgmental, and available before commitment. Avoid gamification, streak pressure, countdown pressure, or punitive copy."
|
||||
],
|
||||
"dataModel": [
|
||||
{
|
||||
"entity": "mutual_discovery_sessions",
|
||||
"fields": ["id", "couple_id", "created_at", "status", "mood", "shared_comfort_ceiling", "expires_at"]
|
||||
},
|
||||
{
|
||||
"entity": "mutual_discovery_session_participants",
|
||||
"fields": ["session_id", "user_id", "private_comfort_selection", "completed_at", "withdrawn_at"]
|
||||
},
|
||||
{
|
||||
"entity": "shared_actions",
|
||||
"fields": ["id", "couple_id", "match_id", "status", "created_by", "saved_at", "deferred_at", "withdrawn_at", "expires_at"]
|
||||
},
|
||||
{
|
||||
"entity": "shared_action_events",
|
||||
"fields": ["id", "shared_action_id", "actor_id", "event_type", "created_at"]
|
||||
},
|
||||
{
|
||||
"entity": "catalogue_review",
|
||||
"fields": ["content_id", "content_kind", "content_status", "reviewed_by", "reviewed_at", "review_reason"]
|
||||
}
|
||||
],
|
||||
"importFormat": {
|
||||
"description": "No bulk import. Developer-authored prompts and packs use the existing audited admin authoring workflow and must remain unreviewed and ineligible until an editorial decision is recorded and server enforcement is verified.",
|
||||
"example": {}
|
||||
},
|
||||
"rules": [
|
||||
"Mutuality is server-calculated from couple-scoped inputs. A client must never infer mutuality from its own answer or a local cache.",
|
||||
"The shared comfort ceiling is the minimum of two private selections, but neither private selection is returned to the partner or emitted in analytics.",
|
||||
"A no produces no mutual match. Existing stored maybe semantics remain backwards compatible until a separately approved session contract changes them.",
|
||||
"Existing `max_intensity` and pack selection gates are not a per-session private comfort protocol and must not be presented as one.",
|
||||
"Current dares have sender and recipient roles. Reuse only a truthful optional handoff after the mutual shared-action domain exists; do not map shared action states onto dare states.",
|
||||
"All API reads and writes enforce authenticated couple isolation server-side. Unauthorized or cross-couple identifiers return non-disclosing failures.",
|
||||
"New developer-authored adult-oriented prompts cannot be activated until universal question and pack eligibility enforcement is proven in the active API path.",
|
||||
"Push payloads remain generic and do not include question, answer, match, action, or intimate content text.",
|
||||
"A new question interaction type, response payload, configuration shape, or match semantic updates docs/question-types.md and passes lint:harness in its implementation story.",
|
||||
"OTA eligibility is assessed per final diff. JavaScript and assets are only OTA candidates when native code, dependencies, permissions, entitlements, config-plugin output, Expo SDK, ios/, android/, expo.version, and runtimeVersion remain unchanged."
|
||||
],
|
||||
"qualityGates": [
|
||||
"npm run lint:harness",
|
||||
"npm run verify:fast",
|
||||
"npm run verify:full",
|
||||
"Focused API route and integration tests for every changed domain",
|
||||
"Focused mobile Jest tests, TypeScript check, lint, and observable iOS/Android simulator evidence for every changed mobile UI flow",
|
||||
"Read-only OTA preflight and doctor checks for the targeted channel and platform before any separately authorized OTA publish",
|
||||
"Live production compatibility and end-to-end read-back before a production release, including deployed API compatibility for any client change"
|
||||
],
|
||||
"stories": [
|
||||
{
|
||||
"id": "US-001",
|
||||
"title": "Establish release and live-state gates for mutual discovery",
|
||||
"status": "open",
|
||||
"dependsOn": [],
|
||||
"description": "As a release owner, I want a verified compatibility record before labelling a client slice OTA-safe so that an update reaches the intended installed app without assuming source equals production.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 0, release readiness.",
|
||||
"Deployment classification: operations and live-state verification, no OTA or store release in this story.",
|
||||
"Record read-only evidence that public iOS source is version/runtime 1.0.8, commit 3659cc8 records build 49, and the production XPREM doctor serves v2 clients; separately record whether a real installed iOS build 49 receives a signed production manifest for runtime 1.0.8.",
|
||||
"Record Android installed-build, XPREM header, certificate, channel, runtime, and manifest evidence independently; until then mark Android OTA eligibility unverified.",
|
||||
"Map every deployed consumer for the proposed API contracts and identify backward-compatible response handling required before the first backend rollout.",
|
||||
"Example: an installed iOS build reports runtime 1.0.8 and receives the expected signed production manifest, so a JS-only iOS Quick Spark diff may proceed to release review.",
|
||||
"Negative case: source contains runtime 1.0.8 but the installed binary does not request or accept that manifest, so no OTA is published and a native/store remediation is planned."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-002",
|
||||
"title": "Prove universal reviewed-question eligibility before activating new content",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-001"],
|
||||
"description": "As a catalogue owner, I want the active recommendation and pack APIs to fail closed on review status so that a new Quick Spark prompt cannot become customer-visible before editorial approval.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 0, safety prerequisite.",
|
||||
"Deployment classification: API/data contract work, no OTA path by itself.",
|
||||
"Trace the active standalone API recommendation and pack endpoints, not only legacy Supabase migrations, and document the effective review-status behavior before changing it.",
|
||||
"If enforcement is absent, implement the smallest server-side, migration-backed eligibility contract with admin authoring parity and preserve current allowed content availability.",
|
||||
"Ensure a stale mobile client cannot retrieve or answer an unreviewed or archived developer-authored question through any recommendation, pack-detail, or answer route.",
|
||||
"Example: an allowed question in an enabled pack remains selectable for a paired couple.",
|
||||
"Negative case: an unreviewed question is returned by neither a direct pack request nor a recommendation, even when a stale client sends its id."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-003",
|
||||
"title": "Add a Quick Spark mood entry shell",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-001", "US-002"],
|
||||
"description": "As a paired user, I want to choose a gentle mood before a short private session so that the discovery experience feels intentional rather than like an endless feed.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 1, OTA-first client foundation.",
|
||||
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||
"Add a thin route entry and focused swipe-feature components for a reviewed fixed mood taxonomy, selected locally for this prototype with no new persistence contract.",
|
||||
"Show paired, loading, no-pack, unpaired, and retry states using existing truthful states; do not promise mutual outcomes to an unpaired user whose answers remain sealed.",
|
||||
"Provide stable accessibility labels or testIDs for mood choices and continue action.",
|
||||
"Example: a paired user chooses 'playful' and continues into the existing question flow with a clear short-session explanation.",
|
||||
"Negative case: an unpaired user cannot be shown copy implying that their private answer will match with a partner."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-004",
|
||||
"title": "Present a finite Quick Spark card sequence",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-003"],
|
||||
"description": "As a user in Quick Spark, I want a finite 5-8 prompt session with honest progress so that I can participate without open-ended pressure.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 1, OTA-first client foundation.",
|
||||
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||
"Compose the sequence only from already eligible questions returned by the current API and preserve existing answer, skip, upload, retry, and sealed-answer behavior.",
|
||||
"Render progress as completed prompts out of the actual session length and make skip available without punitive copy or a lost-progress state.",
|
||||
"Keep route files thin and add focused tests for sequence construction, progress, and early exit.",
|
||||
"Example: a session with six eligible questions shows progress from 1 of 6 through 6 of 6 and ends in a completion state.",
|
||||
"Negative case: with fewer than five eligible questions, the UI does not pad with ineligible or repeated prompts and instead gives a truthful fallback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-005",
|
||||
"title": "Add privacy-preserving Quick Spark completion and existing-talk handoff",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-004"],
|
||||
"description": "As a user who finishes a short session, I want a clear private completion state and a way to talk about an existing mutual match so that the app supports choice without inventing a new server state.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 1, OTA-first client foundation.",
|
||||
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||
"Use only existing server-calculated match data and route Talk to the existing match conversation; do not persist a new Save or shared-action state in local storage as if it were couple-shared.",
|
||||
"If no mutual result exists yet because the partner has not answered, show a neutral waiting state without exposing the current user's answer or implying partner rejection.",
|
||||
"Make completion, Talk, and dismiss controls accessible and testable.",
|
||||
"Example: both partners answer yes to an eligible swipe prompt and the completion outcome can open that existing match conversation.",
|
||||
"Negative case: one partner answers yes and the other has not answered, so no prompt text, answer, or individual interest is disclosed."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-006",
|
||||
"title": "Create the per-session private comfort schema and API contract",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-001", "US-002"],
|
||||
"description": "As a couple, I want each partner to privately choose a comfort level that produces a shared ceiling so that selection is bounded without exposing who chose the lower level.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 2, server foundation.",
|
||||
"Deployment classification: database migration and API deployment required; no OTA path by itself.",
|
||||
"Add a committed Drizzle migration and a standalone API contract for a couple-scoped session and one private selection per participant, with idempotency and explicit session lifecycle validation.",
|
||||
"Calculate and return only a shared ceiling suitable for the caller. Do not return either participant's private selection to the other participant or analytics payloads.",
|
||||
"Enforce authentication, couple isolation, participant membership, duplicate-submission handling, and non-disclosing errors for foreign session ids.",
|
||||
"Example: selections 2 and 4 yield a shared ceiling of 2 while each partner can only retrieve the shared result.",
|
||||
"Negative case: a user outside the couple cannot read, set, or infer any session or individual selection."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-007",
|
||||
"title": "Apply shared comfort selection to server-side Quick Spark composition",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-006"],
|
||||
"description": "As a session participant, I want the prompt set selected under our shared ceiling so that the content respects both private choices.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 2, server foundation.",
|
||||
"Deployment classification: API deployment required; a later compatible mobile consumer can be OTA-delivered only after US-001 platform gates pass.",
|
||||
"Add a couple-scoped session recommendation endpoint or extend a documented endpoint so the API filters reviewed, enabled, unanswered prompts by the calculated session ceiling.",
|
||||
"Preserve existing recommendation behavior for non-session clients and document any backward-compatible response fields before rollout.",
|
||||
"Ensure response ordering does not leak an excluded higher-intensity prompt or either participant's private selection.",
|
||||
"Example: a shared ceiling of 2 returns only eligible intensity 1-2 reviewed prompts from enabled inventory.",
|
||||
"Negative case: an intensity 3 prompt is not returned or inferable when one participant privately selected 2."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-008",
|
||||
"title": "Wire private comfort selection and server-backed Quick Spark into mobile",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-004", "US-006", "US-007"],
|
||||
"description": "As a paired user, I want to select my comfort privately and receive only the shared session outcome so that the client enforces the same privacy model as the API.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 2, client integration.",
|
||||
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval and deployed API compatibility read-back; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||
"Replace prototype-only composition with the authenticated session API and handle loading, incomplete-partner, expired-session, retry, and non-disclosing failure states.",
|
||||
"Render only the caller's choice before submission and only the returned shared ceiling afterwards. Clear user-scoped local state at sign-out.",
|
||||
"Do not cache, log, or send individual comfort values to analytics beyond the server request needed to create the session.",
|
||||
"Example: a user chooses a private level, resumes later, and sees a waiting state until both participants have selected without seeing the partner's value.",
|
||||
"Negative case: a replayed response or API error cannot leave another account's session or private value visible after sign-out/sign-in."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-009",
|
||||
"title": "Create a durable shared-action state machine",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-001"],
|
||||
"description": "As a couple with a mutual match, I want a shared action record with neutral lifecycle states so that Talk, Save, Defer, softer alternative, and withdrawal are truthful and do not overload the existing dare model.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 3, server foundation.",
|
||||
"Deployment classification: database migration and API deployment required; no OTA path by itself.",
|
||||
"Define and implement a committed migration, server-side state machine, actor permissions, expiry policy, and match linkage for shared actions independently of sent_dares.",
|
||||
"Support the approved vocabulary for save, defer, decline, softer alternative, withdrawal, and completion only where their semantics are explicit; ensure a participant can withdraw without exposing a private reason.",
|
||||
"Return generic, non-attributing partner-visible state copy and use generic push previews if notifications are later introduced.",
|
||||
"Example: either member saves a mutual match for later and both can see the neutral saved state.",
|
||||
"Negative case: a sender-to-recipient dare transition cannot be used to imply that a shared action was accepted, completed, or declined."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-010",
|
||||
"title": "Expose couple-isolated shared-action API routes and tests",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-009"],
|
||||
"description": "As a mobile client, I want a documented HTTP contract for shared actions so that the UI can offer optional choices without relying on client-only truth.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 3, server foundation.",
|
||||
"Deployment classification: API deployment required; no OTA path by itself.",
|
||||
"Provide versioned read and mutation routes with schema validation, idempotency where retries are possible, non-disclosing cross-couple failures, and backward-compatible match responses.",
|
||||
"Test every legal and illegal state transition, both partner roles, withdrawn and expired behavior, and match ownership.",
|
||||
"Document the participant-visible contract and any interaction-type or match-semantics change in the maintained documentation in the same implementation.",
|
||||
"Example: either partner can defer a saved shared action and the API returns a neutral deferred representation to both.",
|
||||
"Negative case: a caller cannot mutate, list, or learn the existence of an action outside their couple."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-011",
|
||||
"title": "Add optional shared-action choices to mutual match outcomes",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-005", "US-010"],
|
||||
"description": "As a couple viewing a mutual match, I want equal, optional Talk, Save, and shared-action choices so that mutual discovery becomes a real-world possibility without pressure.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 3, client integration.",
|
||||
"Deployment classification: likely iOS OTA-compatible JavaScript and assets after US-001 canary approval and deployed API compatibility read-back; Android remains unverified; native/store build if the final diff changes the installed native surface.",
|
||||
"Render shared-action UI only for server-confirmed mutual matches and use the shared-action API as the source of truth for save, defer, softer alternative, withdrawal, and completion state.",
|
||||
"Keep Talk routed to existing match chat, keep existing Dares visually and semantically distinct, and make all action choices optional and dismissible.",
|
||||
"Include accessible labels/testIDs and focused tests for loading, retry, stale response, and each safe state.",
|
||||
"Example: a mutual match can be saved, then later shown as saved to both partners without exposing who initiated it.",
|
||||
"Negative case: an unmatched question, a sealed solo answer, or a stale local match cannot create or display a shared action."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-012",
|
||||
"title": "Add a reviewed Tell Me Privately session variant",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-002", "US-008"],
|
||||
"description": "As a couple, I want a text or audio-led private response option with a both-partner reveal so that we can share a thought without an explicit content catalogue.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 4, controlled content expansion.",
|
||||
"Deployment classification: question-type changes require shared contract, API, admin, database, and mobile deployment; it is not OTA-only. A client renderer after the deployed contract may be an OTA candidate subject to US-001 gates.",
|
||||
"First determine whether existing text_answer and audio types can meet the approved experience without changing payload or match semantics; prefer configuration and reviewed content if they can.",
|
||||
"If a new interaction type, payload, configuration, or match semantic is necessary, change the shared union, committed database constraint, API, admin authoring, mobile renderer, docs/question-types.md, and harness contract together.",
|
||||
"Ensure both-partner reveal preserves existing response-summary privacy and does not put user-authored text or audio in analytics or push previews.",
|
||||
"Example: two partners submit allowed text answers and see the existing both_answered match representation with a Talk option.",
|
||||
"Negative case: one partner's private text or audio cannot be revealed, notified, or analytically recorded before the other required response exists."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "US-013",
|
||||
"title": "Create privacy-safe measurement and release decision review",
|
||||
"status": "open",
|
||||
"dependsOn": ["US-008", "US-011", "US-012"],
|
||||
"description": "As a product and safety owner, I want a reviewable aggregate measurement plan and staged release decision so that adoption never outruns consent, safety, or platform compliance.",
|
||||
"acceptanceCriteria": [
|
||||
"Phase: 5, controlled rollout readiness.",
|
||||
"Deployment classification: analytics/API/client work plus release operations; OTA eligibility is evaluated per final client diff and does not authorize publishing.",
|
||||
"Define a typed event schema containing only aggregate category and lifecycle fields, with a test that rejects prompt text, answer text, media paths, private comfort values, and intimate match detail.",
|
||||
"Set pre-agreed stop and review thresholds for reports, blocks, skips, defers, declines, withdrawals, and error rates; do not frame safety signals as conversion loss to suppress.",
|
||||
"Require production read-back of deployed API compatibility, content review evidence, UGC moderation and incident controls, and platform-specific OTA canary before a separately authorized staged release.",
|
||||
"Example: an aggregate session_completed event records mood category and completion state but no prompt or response content.",
|
||||
"Negative case: a telemetry payload containing a response text field, media path, or individual comfort value fails validation and is not sent."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: release-mobile
|
||||
description: Prepare, verify, and ship Sauci iOS or Android releases end to end, including store uploads, review submission, and rollout.
|
||||
description: Prepare and verify Sauci iOS or Android releases and store uploads.
|
||||
---
|
||||
|
||||
Read `.codex/skills/release-mobile/SKILL.md` completely and follow it.
|
||||
@@ -13,13 +13,9 @@ drafting notes and [references/artifact-verification.md](references/artifact-ver
|
||||
before accepting a build.
|
||||
|
||||
A request to generate or create a release authorizes the scoped version edits,
|
||||
release notes, local builds, release commits, and one local annotated tag. When
|
||||
the request also asks to ship, upload, submit, or release to the stores, it
|
||||
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.
|
||||
release notes, local builds, release commits, and one local annotated tag. It
|
||||
does not authorize pushing commits or tags, uploading binaries, submitting a
|
||||
store release, changing signing credentials, or regenerating native projects.
|
||||
|
||||
## Defaults
|
||||
|
||||
@@ -43,8 +39,6 @@ Require all of the following:
|
||||
- consistent public and OTA runtime versions across `app.json`, explicit
|
||||
`app.config.js` runtime, Android, and every iOS target
|
||||
(`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;
|
||||
- the configured production API/Auth/RevenueCat environment, without printing
|
||||
secret values.
|
||||
@@ -86,15 +80,6 @@ Build from the clean prepared source using the package scripts in
|
||||
versioned artifact directory. When building both platforms, build Android first,
|
||||
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:
|
||||
|
||||
1. Inspect the worktree before doing anything else. Accept only the documented
|
||||
@@ -103,19 +88,15 @@ After each build:
|
||||
dependency, or native-project regeneration as a failed build.
|
||||
2. Independently inspect the binary for package/bundle identifier, public
|
||||
version, platform build number, production endpoint configuration where
|
||||
inspectable, signing identity/fingerprint, OTA runtime/channel/certificate,
|
||||
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.
|
||||
inspectable, and signing identity/fingerprint. Require the embedded public
|
||||
version to equal the requested version.
|
||||
3. Record the verified platform build number in the new release-note entry.
|
||||
4. Commit only the expected metadata and release-note update as
|
||||
`chore(<platform>): record v<version> build <number>`. The worktree must be
|
||||
clean before starting the next platform build.
|
||||
|
||||
Do not accept a successful EAS exit code as proof of a valid artifact. Quarantine
|
||||
or remove rejected artifacts so only accepted paths can be offered for upload. If
|
||||
either requested platform fails, do not tag a partial release.
|
||||
Do not accept a successful EAS exit code as proof of a valid artifact. If either
|
||||
requested platform fails, do not tag a partial release.
|
||||
|
||||
## Tag and prove the result
|
||||
|
||||
|
||||
@@ -7,11 +7,6 @@ description: Create or review Sauci question-pack content and its local migratio
|
||||
|
||||
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”.
|
||||
- Avoid time-bound words such as tonight, now, or today.
|
||||
- Symmetric activities have no partner text.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: release-mobile
|
||||
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.
|
||||
description: Prepare and verify Sauci iOS or Android releases. Use for versioning, EAS/native builds, Fastlane upload, release notes, or store submission preparation.
|
||||
---
|
||||
|
||||
# Mobile release
|
||||
@@ -9,41 +9,6 @@ Read `docs/releasing.md`, `apps/mobile/RELEASING.md` when present, and the mobil
|
||||
scoped instructions. Confirm target platform and release profile. Run repository
|
||||
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,
|
||||
and prove `HEAD` matches its upstream. The `generate-release` skill may instead
|
||||
build a clean, scoped release-preparation commit that descends directly from that
|
||||
@@ -52,65 +17,40 @@ 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
|
||||
`expo prebuild` first; native regeneration is a separate reviewed change.
|
||||
|
||||
Run `npm run release:preflight -w @sauci/mobile` before allocating an EAS build.
|
||||
It rejects inconsistent public/runtime versions, an iOS widget build-setting
|
||||
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.
|
||||
Use the package scripts in `apps/mobile/package.json`; they create artifacts only.
|
||||
Never infer upload or submission authority from build authorization. Store upload
|
||||
uses a separately authorized command.
|
||||
|
||||
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.
|
||||
## Upload authorization gate
|
||||
|
||||
Report provider progress by phase: packaging, native compilation, submission
|
||||
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:
|
||||
After an artifact passes verification, stop immediately before the external
|
||||
upload and ask the user for confirmation. The request must identify:
|
||||
|
||||
- the platform, exact artifact path, bundle/package identifier, public version,
|
||||
build number, signature identity, and SHA-256 digest;
|
||||
- the exact destination and resulting state: which Play track and rollout
|
||||
status, or App Store Connect version state and whether App Review submission
|
||||
and automatic release are included.
|
||||
- the exact destination and resulting state: Google Play internal-track draft or
|
||||
App Store Connect/TestFlight processing;
|
||||
- that the upload does not authorize a public rollout or App Review submission.
|
||||
|
||||
Once these details match the request, proceed through upload, review
|
||||
submission, and rollout without requesting further confirmation.
|
||||
For iOS, inspect the current App Store Connect automatic-distribution behavior
|
||||
before asking. If the build would be distributed automatically, name the exact
|
||||
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.
|
||||
|
||||
- **Android**: `bundle exec fastlane android release aab:<absolute path>`
|
||||
commits the release on the production track (pass `track:` to target another
|
||||
track, `rollout:0.1` for a staged rollout). Play review runs automatically;
|
||||
rollout proceeds on approval. Use the `upload` lane only when the request is
|
||||
explicitly upload-only.
|
||||
- **iOS**: `bundle exec fastlane ios release ipa:<absolute path>` uploads,
|
||||
creates the App Store version, submits for App Review, and releases
|
||||
automatically on approval (pass `phased_release:true` for a 7-day phased
|
||||
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`.
|
||||
Even when the original request includes building, releasing, uploading, or
|
||||
submitting, require this fresh confirmation once the exact verified artifact and
|
||||
destination are known. End the request with: `Upload this verified artifact to
|
||||
<destination> now?` A clear affirmative response authorizes one upload of that
|
||||
artifact to that destination, plus only any automatic TestFlight distribution
|
||||
explicitly disclosed in the request. Run `eas submit` with the explicit
|
||||
`--platform`, `--profile production`, `--path`, and `--wait` arguments; never
|
||||
select `--latest`. If the result is ambiguous, inspect EAS and the store before
|
||||
doing anything else, do not retry, and ask again before another upload.
|
||||
|
||||
App Store Connect API group association does not establish the automatic-
|
||||
distribution switch. Verify that switch in an authenticated App Store Connect UI
|
||||
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.
|
||||
Apply this gate to every upload method, including EAS, Fastlane, Xcode Organizer,
|
||||
and the store consoles. A later public rollout, App Review submission, or
|
||||
additional TestFlight distribution requires another fresh authorization.
|
||||
|
||||
Preserve platform signing and local EAS versioning. After a build, verify that
|
||||
tracked changes are limited to the expected version metadata, then independently
|
||||
@@ -118,30 +58,5 @@ read the artifact version and signature. For an iOS IPA, inspect every packaged
|
||||
app extension as well as the containing app and require matching
|
||||
`CFBundleShortVersionString` and `CFBundleVersion` values. Treat Xcode archive
|
||||
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, 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.
|
||||
reject or rebuild only after reading the exported IPA itself. Report the artifact,
|
||||
version/build number, upload result when authorized, and store read-back state.
|
||||
@@ -39,11 +39,3 @@ terraform
|
||||
.claude
|
||||
.codex
|
||||
.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
|
||||
@@ -24,14 +24,9 @@ jobs:
|
||||
echo "master is frozen; target staging or main" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$BASE_BRANCH" = "main" ]; then
|
||||
case "$HEAD_BRANCH" in
|
||||
staging|hotfix/*|release/*) ;;
|
||||
*)
|
||||
echo "main takes staging promotions, or a hotfix/* or release/* branch for direct work" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$BASE_BRANCH" = "main" ] && [ "$HEAD_BRANCH" != "staging" ]; then
|
||||
echo "Only staging may be promoted to main" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$BASE_BRANCH" != "staging" ] && [ "$BASE_BRANCH" != "main" ]; then
|
||||
echo "Feature pull requests target staging; promotions target main" >&2
|
||||
|
||||
@@ -40,10 +40,6 @@ MCP service in one npm/Turborepo workspace.
|
||||
11. Use high-effort reviewers only for the risk triggers in
|
||||
`docs/agents/routing.md`.
|
||||
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
|
||||
|
||||
@@ -80,7 +76,6 @@ environment limitation; do not call it a passing product check.
|
||||
|---|---|
|
||||
| System boundaries and data flow | `docs/architecture/system.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` |
|
||||
| Database migration safety | `docs/operations/database-migrations.md` |
|
||||
| Agent roles and effort routing | `docs/agents/routing.md` |
|
||||
@@ -91,9 +86,6 @@ environment limitation; do not call it a passing product check.
|
||||
## Documentation discipline
|
||||
|
||||
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
|
||||
lesson becomes permanent only after it is reproduced and promoted into a doc,
|
||||
skill, fixture, or executable check.
|
||||
@@ -1,20 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
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(' ');
|
||||
}
|
||||
@@ -10,22 +10,36 @@ import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recha
|
||||
import { format, subDays, startOfDay, endOfDay, formatDistanceToNow } from 'date-fns';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import {
|
||||
formatFeatureName,
|
||||
featureInterestKey,
|
||||
toRecentFeatureInterest,
|
||||
type FeatureInterestRow,
|
||||
type RecentFeatureInterest,
|
||||
} from '@/lib/featureInterests';
|
||||
|
||||
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 = {
|
||||
feature_name: string;
|
||||
opt_in_count: 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() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
|
||||
@@ -81,7 +95,7 @@ export function DashboardPage() {
|
||||
? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
|
||||
: Promise.resolve({ data: [] }),
|
||||
canViewUsers
|
||||
? adminData.from<FeatureInterestRow>('feature_interests').select('user_id, feature, created_at').order('created_at', { ascending: false }).limit(10)
|
||||
? adminData.from('feature_interests').select('id, user_id, feature_name, created_at').order('created_at', { ascending: false }).limit(10)
|
||||
: Promise.resolve({ data: [] }),
|
||||
]);
|
||||
|
||||
@@ -144,7 +158,12 @@ export function DashboardPage() {
|
||||
}))
|
||||
);
|
||||
|
||||
let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(toRecentFeatureInterest);
|
||||
let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(item => ({
|
||||
id: item.id,
|
||||
user_id: item.user_id,
|
||||
feature_name: item.feature_name,
|
||||
created_at: item.created_at,
|
||||
}));
|
||||
|
||||
if (canViewUsers && recentActivity.length > 0) {
|
||||
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
|
||||
@@ -560,14 +579,14 @@ export function DashboardPage() {
|
||||
if (item.profile?.name && item.profile.email) {
|
||||
subtitleParts.push(item.profile.email);
|
||||
}
|
||||
subtitleParts.push(formatFeatureName(item.feature));
|
||||
subtitleParts.push(formatFeatureName(item.feature_name));
|
||||
|
||||
const fallbackChar = (item.profile?.name || item.profile?.email || 'U')
|
||||
.charAt(0)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div key={featureInterestKey(item)} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3">
|
||||
<div key={item.id} 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">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={item.profile?.avatar_url || undefined} />
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
-- 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 $$;
|
||||
@@ -162,13 +162,6 @@
|
||||
"when": 1788168150000,
|
||||
"tag": "0022_solo_sealed_answers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1788624000000,
|
||||
"tag": "0023_dare_catalogue_150",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -33,7 +33,6 @@ export interface CoupleRepository {
|
||||
getState(userId: string): Promise<CoupleStateResponse>;
|
||||
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
|
||||
join(userId: string, inviteCode: string): Promise<Couple>;
|
||||
cancelInvite(userId: string): Promise<void>;
|
||||
cancel(userId: string): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -269,43 +268,6 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async cancelInvite(userId: string): Promise<void> {
|
||||
await transaction(this.pool, async (client) => {
|
||||
// Lock in the same order as join(): the caller's profile first, then the
|
||||
// couple. A concurrent join therefore either completes first and makes
|
||||
// this a safe no-op error, or observes that the invite no longer exists.
|
||||
const profile = await profileForUpdate(client, userId);
|
||||
if (!profile.couple_id) {
|
||||
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
|
||||
}
|
||||
|
||||
const coupleResult = await client.query<{ id: string }>(
|
||||
'select id from couples where id = $1 for update',
|
||||
[profile.couple_id],
|
||||
);
|
||||
if (!coupleResult.rows[0]) {
|
||||
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
|
||||
}
|
||||
|
||||
const members = await client.query<{ id: string }>(
|
||||
'select id from profiles where couple_id = $1',
|
||||
[profile.couple_id],
|
||||
);
|
||||
if (members.rows.length > 1) {
|
||||
throw new CoupleError('invite_already_joined', 'This invite has already been joined', 409);
|
||||
}
|
||||
if (members.rows.length !== 1 || members.rows[0]?.id !== userId) {
|
||||
throw new CoupleError('not_paired', 'You do not have an invite to cancel', 400);
|
||||
}
|
||||
|
||||
// At this point the couple has precisely the authenticated creator as a
|
||||
// member. Removing it cannot delete paired data. Answers made before
|
||||
// invite creation stay sealed; answers made while waiting are coupled
|
||||
// and are deleted with the cancelled invite.
|
||||
await client.query('delete from couples where id = $1', [profile.couple_id]);
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
|
||||
@@ -50,9 +50,4 @@ export function registerCoupleRoutes(app: AuthenticatedApp, service: CoupleServi
|
||||
const result = await route(() => service.cancel(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
|
||||
app.delete('/v1/couple/invite', async (c) => {
|
||||
const result = await route(() => service.cancelInvite(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
}
|
||||
@@ -41,9 +41,4 @@ export class CoupleService {
|
||||
await this.repository.cancel(userId);
|
||||
return { success: true, couple_id: null };
|
||||
}
|
||||
|
||||
async cancelInvite(userId: string): Promise<CoupleMutationResponse> {
|
||||
await this.repository.cancelInvite(userId);
|
||||
return { success: true, couple_id: null };
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ export type CoupleErrorCode =
|
||||
| 'invalid_invite_code'
|
||||
| 'couple_full'
|
||||
| 'not_paired'
|
||||
| 'invite_already_joined'
|
||||
| 'invite_code_collision';
|
||||
|
||||
export class CoupleError extends Error {
|
||||
|
||||
@@ -68,56 +68,6 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
|
||||
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
|
||||
});
|
||||
|
||||
it('cancels a waiting invite only for its creator', async () => {
|
||||
const coupleId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||
const pack = 'abababab-abab-4bab-8bab-abababababab';
|
||||
const sealedQuestion = 'acacacac-acac-4cac-8cac-acacacacacac';
|
||||
const waitingQuestion = 'adadadad-adad-4dad-8dad-adadadadadad';
|
||||
await pool.query('insert into question_packs(id, name) values ($1, $2)', [pack, 'Invite cancellation']);
|
||||
await pool.query(
|
||||
'insert into questions(id, pack_id, text) values ($1, $3, $2), ($4, $3, $5)',
|
||||
[sealedQuestion, 'Sealed before invite', pack, waitingQuestion, 'Answered while waiting'],
|
||||
);
|
||||
await pool.query(
|
||||
"insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, 'yes')",
|
||||
[randomUUID(), charlie, sealedQuestion],
|
||||
);
|
||||
await repository.create(charlie, coupleId, 'WAIT2345');
|
||||
await pool.query(
|
||||
"insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, $4, 'no')",
|
||||
[randomUUID(), charlie, waitingQuestion, coupleId],
|
||||
);
|
||||
|
||||
await repository.cancelInvite(charlie);
|
||||
|
||||
await expect(repository.getState(charlie)).resolves.toEqual({ couple: null, partner: null, sealed_count: 1 });
|
||||
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(0);
|
||||
expect((await pool.query('select count(*)::int as count from responses where question_id = $1', [sealedQuestion])).rows[0].count).toBe(1);
|
||||
expect((await pool.query('select count(*)::int as count from responses where question_id = $1', [waitingQuestion])).rows[0].count).toBe(0);
|
||||
});
|
||||
|
||||
it('never deletes a joined couple through the invite cancellation path', async () => {
|
||||
const coupleId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
await repository.create(alice, coupleId, 'SAFE2345');
|
||||
await repository.join(bob, 'SAFE2345');
|
||||
|
||||
const before = await pool.query<{ id: string; couple_id: string | null }>(
|
||||
'select id, couple_id from profiles where id in ($1, $2) order by id',
|
||||
[alice, bob],
|
||||
);
|
||||
await expect(repository.cancelInvite(alice)).rejects.toMatchObject({
|
||||
code: 'invite_already_joined',
|
||||
status: 409,
|
||||
});
|
||||
|
||||
const after = await pool.query<{ id: string; couple_id: string | null }>(
|
||||
'select id, couple_id from profiles where id in ($1, $2) order by id',
|
||||
[alice, bob],
|
||||
);
|
||||
expect(after.rows).toEqual(before.rows);
|
||||
expect((await pool.query('select count(*)::int as count from couples where id = $1', [coupleId])).rows[0].count).toBe(1);
|
||||
});
|
||||
|
||||
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
|
||||
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { AuthIdentity } from '../src/auth.js';
|
||||
import type { CoupleRepository } from '../src/domains/couples/repository.js';
|
||||
import { registerCoupleRoutes } from '../src/domains/couples/routes.js';
|
||||
import { CoupleService } from '../src/domains/couples/service.js';
|
||||
import { CoupleError } from '../src/domains/couples/types.js';
|
||||
|
||||
const identity: AuthIdentity = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
@@ -26,7 +25,6 @@ function setup() {
|
||||
invite_code: inviteCode,
|
||||
created_at: '2026-08-27T00:00:00.000Z',
|
||||
})),
|
||||
cancelInvite: vi.fn(async () => undefined),
|
||||
cancel: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => undefined),
|
||||
};
|
||||
@@ -82,28 +80,4 @@ describe('couple routes', () => {
|
||||
expect(await response.json()).toEqual({ success: true, couple_id: null });
|
||||
expect(repository.cancel).toHaveBeenCalledWith(identity.id);
|
||||
});
|
||||
|
||||
it('cancels only the authenticated user\'s waiting invite on the dedicated route', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ success: true, couple_id: null });
|
||||
expect(repository.cancelInvite).toHaveBeenCalledWith(identity.id);
|
||||
expect(repository.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not turn a joined invite into relationship deletion', async () => {
|
||||
const { app, repository } = setup();
|
||||
vi.mocked(repository.cancelInvite).mockRejectedValueOnce(
|
||||
new CoupleError('invite_already_joined', 'This invite has already been joined', 409),
|
||||
);
|
||||
|
||||
const response = await app.request('/v1/couple/invite', { method: 'DELETE' });
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toEqual({
|
||||
error: { code: 'invite_already_joined', message: 'This invite has already been joined' },
|
||||
});
|
||||
expect(repository.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,6 @@ function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository
|
||||
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
|
||||
create: vi.fn(async () => couple),
|
||||
join: vi.fn(async () => couple),
|
||||
cancelInvite: vi.fn(async () => undefined),
|
||||
cancel: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
@@ -62,13 +61,4 @@ describe('CoupleService', () => {
|
||||
expect(create).toHaveBeenCalledTimes(2);
|
||||
expect(create.mock.calls.every(([userId]) => userId === 'user-one')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the invite-only cancellation operation for a waiting creator', async () => {
|
||||
const repo = repository();
|
||||
const service = new CoupleService(repo);
|
||||
|
||||
await expect(service.cancelInvite('user-one')).resolves.toEqual({ success: true, couple_id: null });
|
||||
expect(repo.cancelInvite).toHaveBeenCalledWith('user-one');
|
||||
expect(repo.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
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 { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js';
|
||||
@@ -85,133 +82,6 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
|
||||
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),
|
||||
migrationsSchema: migrationSchema,
|
||||
});
|
||||
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 () => {
|
||||
await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' });
|
||||
await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' });
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -2,21 +2,6 @@
|
||||
module.exports = {
|
||||
extends: 'expo',
|
||||
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: [
|
||||
{
|
||||
files: ['plugins/**/*.js'],
|
||||
|
||||
@@ -11,6 +11,5 @@ expo-env.d.ts
|
||||
*.keystore
|
||||
# Fastlane
|
||||
fastlane-key.json
|
||||
fastlane/.env
|
||||
fastlane/report.xml
|
||||
fastlane/README.md
|
||||
@@ -13,5 +13,3 @@ native iOS/Android targets.
|
||||
- Add stable accessibility labels or `testID` values for critical interactions.
|
||||
- Verify focused Jest tests, typecheck, lint, then native/Expo behavior as risk
|
||||
requires. UI work needs observable simulator evidence.
|
||||
- Before and after any `expo prebuild`, follow the "After native regeneration"
|
||||
checklist in `docs/releasing.md`.
|
||||
@@ -12,8 +12,5 @@ local.properties
|
||||
*.hprof
|
||||
.cxx/
|
||||
|
||||
# generated inline modules
|
||||
app/src/main/java/inline/
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
@@ -11,7 +11,7 @@ def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
|
||||
react {
|
||||
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()
|
||||
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"
|
||||
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/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()
|
||||
|
||||
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
|
||||
@@ -64,7 +64,7 @@ react {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
|
||||
* Set this to true in release builds to optimize the app using R8.
|
||||
*/
|
||||
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
|
||||
|
||||
@@ -92,9 +92,8 @@ android {
|
||||
applicationId 'com.sauci.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 51
|
||||
versionName "1.0.8"
|
||||
|
||||
versionCode 49
|
||||
versionName "1.0.7"
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
signingConfigs {
|
||||
@@ -183,3 +182,53 @@ dependencies {
|
||||
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<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">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<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_VIDEO" tools:node="remove"/>
|
||||
@@ -12,7 +12,7 @@
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" tools:replace="android:maxSdkVersion"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="com.android.vending.BILLING"/>
|
||||
<queries>
|
||||
<package android:name="com.instagram.android"/>
|
||||
@@ -22,7 +22,7 @@
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</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:enableOnBackInvokedCallback="false" android:requestLegacyExternalStorage="true" android:fullBackupContent="@xml/secure_store_backup_rules" android:dataExtractionRules="@xml/secure_store_data_extraction_rules">
|
||||
<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">
|
||||
<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="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
|
||||
@@ -30,14 +30,13 @@
|
||||
<meta-data android:name="expo.modules.updates.CODE_SIGNING_CERTIFICATE" android:value="-----BEGIN CERTIFICATE-----
MIICzTCCAbWgAwIBAgIJAPrPjxnUOGisMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV
BAMTBVNhdWNpMB4XDTI2MDgzMDE4MTAzMFoXDTM2MDgzMDE4MTAzMFowEDEOMAwG
A1UEAxMFU2F1Y2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWiUac
8i9bIbNZjv8iunVr6kqlgb9vea98JHz/A83biyszg0+GpmLYsr4zV2F7A6RitiIx
SOML+wVedyD0F2quWccBGXinvMAds0TxgIOjQfP+8+VJ/duDjveHvn+OFW4s7XxW
SY7TK1ufDcPWo/f5XzfqtBveug49zv3/F3MwfmMUbEY/zvH7nayPGJNuSyf3wISn
JIt0BsHNhWykRXCzjaF2fy3nAebOyezbtbapCjUlxPENVtBCzBQ7C9kaQXCBw5mV
hxhJlOxHWXpHJgDLHpev8A/CWQRh67tsevjKi6R6SDlf0R0xSbDJTElx8goWOOQW
VO9aDqI+oH0KpWfnAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E
DDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAyUc20bIRCxoBTJg84mPl
2nFDWSaAfzgo0K0b6x2T1suuq4LAOUgzBqJISrQAALd0besK1qUZSAMhjQu1wPZT
MHuWRMxQHfOkyHMYUfOoPMS4bE+4K0WjzfoVt3y1A+oxhcIAQUUGyNgNmebfEN07
JlX0B8UI8aIMjyfpsIDgZx1piHKwittGy/V4MPOLbmT2BBwS9BELcmWE4CwWbfeo
JM4lXY0hk3M/GTstRYaUKiwBWEU+z1PdAmWAgrtxpVnXN+oaToWh0WbVkbF1K6r5
3oBGFTL7kT+Wogg9GK04XoAREw0jZJ/k77oJx68lGVBNiB+Ue2KqX0H/0cf2CslY
Ug==
-----END CERTIFICATE-----
"/>
|
||||
<meta-data android:name="expo.modules.updates.CODE_SIGNING_METADATA" android:value="{"keyid":"main","alg":"rsa-v1_5-sha256"}"/>
|
||||
<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_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_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="{"expo-channel-name":"production","expo-app-id":"9a66f3c2-fda7-416f-87e2-6db833b32e9d","xprem-branch":""}"/>
|
||||
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
|
||||
<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">
|
||||
<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">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
@@ -47,6 +46,7 @@
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="app.sauci"/>
|
||||
<data android:scheme="com.sauci.app"/>
|
||||
<data android:scheme="exp+sauci"/>
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true" data-generated="true">
|
||||
@@ -56,7 +56,7 @@
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<receiver android:name=".widget.LiveDraw" android:exported="false" android:label="Live Draw">
|
||||
<receiver android:name=".widget.LiveDraw" android:exported="true" android:label="Live Draw">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
|
||||
<action android:name="com.sauci.app.WIDGET_CLICK"/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
package com.sauci.app
|
||||
import expo.modules.splashscreen.SplashScreenManager
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -16,10 +15,7 @@ class MainActivity : ReactActivity() {
|
||||
// Set the theme to AppTheme BEFORE onCreate to support
|
||||
// coloring the background, status bar, and navigation bar.
|
||||
// This is required for expo-splash-screen.
|
||||
// setTheme(R.style.AppTheme);
|
||||
// @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
|
||||
SplashScreenManager.registerOnActivity(this)
|
||||
// @generated end expo-splashscreen
|
||||
setTheme(R.style.AppTheme);
|
||||
super.onCreate(null)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,27 +5,40 @@ import android.content.res.Configuration
|
||||
|
||||
import com.facebook.react.PackageList
|
||||
import com.facebook.react.ReactApplication
|
||||
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
|
||||
import com.facebook.react.ReactNativeHost
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ReactHost
|
||||
import com.facebook.react.common.ReleaseLevel
|
||||
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 expo.modules.ApplicationLifecycleDispatcher
|
||||
import expo.modules.ExpoReactHostFactory
|
||||
import expo.modules.ReactNativeHostWrapper
|
||||
|
||||
class MainApplication : Application(), ReactApplication {
|
||||
|
||||
override val reactHost: ReactHost by lazy {
|
||||
ExpoReactHostFactory.getDefaultReactHost(
|
||||
context = applicationContext,
|
||||
packageList =
|
||||
PackageList(this).packages.apply {
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// add(MyReactNativePackage())
|
||||
}
|
||||
)
|
||||
}
|
||||
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
|
||||
this,
|
||||
object : DefaultReactNativeHost(this) {
|
||||
override fun getPackages(): List<ReactPackage> {
|
||||
val packages = PackageList(this).packages
|
||||
// Packages that cannot be autolinked yet can be added manually here, for example:
|
||||
// packages.add(new 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() {
|
||||
super.onCreate()
|
||||
|
||||
@@ -1,6 +1,84 @@
|
||||
package com.sauci.app.widget;
|
||||
|
||||
import com.reactnativeandroidwidget.RNWidgetProvider;
|
||||
import android.appwidget.AppWidgetManager;
|
||||
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;
|
||||
|
||||
public class LiveDraw extends RNWidgetProvider {
|
||||
import com.sauci.app.R;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 10 KiB |
@@ -2,5 +2,6 @@
|
||||
<color name="splashscreen_background">#1a1a2e</color>
|
||||
<color name="iconBackground">#1a1a2e</color>
|
||||
<color name="colorPrimary">#023c69</color>
|
||||
<color name="colorPrimaryDark">#1a1a2e</color>
|
||||
<color name="notification_icon_color">#e94560</color>
|
||||
</resources>
|
||||
@@ -1,7 +1,7 @@
|
||||
<resources>
|
||||
<string name="app_name">Sauci</string>
|
||||
<string name="expo_system_ui_user_interface_style" translatable="false">automatic</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="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</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>
|
||||
</resources>
|
||||
@@ -1,14 +1,18 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.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="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:statusBarColor">#1a1a2e</item>
|
||||
</style>
|
||||
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
|
||||
<item name="postSplashScreenTheme">@style/AppTheme</item>
|
||||
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
|
||||
<style name="ResetEditText" parent="@android:style/Widget.EditText">
|
||||
<item name="android:padding">0dp</item>
|
||||
<item name="android:textColorHint">#c8c8c8</item>
|
||||
<item name="android:textColor">@android:color/black</item>
|
||||
</style>
|
||||
<style name="Theme.App.SplashScreen" parent="AppTheme">
|
||||
<item name="android:windowBackground">@drawable/ic_launcher_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -4,18 +4,10 @@
|
||||
android:minHeight="110dp"
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="2"
|
||||
|
||||
|
||||
|
||||
android:resizeMode="none"
|
||||
|
||||
android:description="@string/widget_livedraw_description"
|
||||
|
||||
android:initialLayout="@layout/rn_widget"
|
||||
android:initialLayout="@layout/widget_live_draw"
|
||||
android:previewImage="@drawable/livedraw_preview"
|
||||
|
||||
|
||||
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen">
|
||||
</appwidget-provider>
|
||||
@@ -1,26 +1,26 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.6'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
classpath('com.android.tools.build:gradle')
|
||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
classpath 'com.google.gms:google-services:4.4.1'
|
||||
classpath('com.android.tools.build:gradle')
|
||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "expo-root-project"
|
||||
apply plugin: "com.facebook.react.rootproject"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url 'https://www.jitpack.io' }
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,12 @@
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
|
||||
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# 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
|
||||
org.gradle.parallel=true
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# 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
|
||||
@@ -41,11 +41,6 @@ newArchEnabled=true
|
||||
# If set to false, you will be using JSC instead.
|
||||
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)
|
||||
expo.gif.enabled=true
|
||||
# Enable webp support in React Native images (~85 KB increase)
|
||||
@@ -60,7 +55,16 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
||||
# Use legacy packaging to compress native libraries in the resulting APK.
|
||||
expo.useLegacyPackaging=false
|
||||
|
||||
expo.inlineModules.watchedDirectories=[]
|
||||
android.compileSdkVersion=36
|
||||
android.targetSdkVersion=36
|
||||
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.buildToolsVersion=36.0.0
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -86,7 +86,8 @@ done
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# 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\n' "$PWD" ) || exit
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
@@ -114,6 +115,7 @@ case "$( uname )" in #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
@@ -171,6 +173,7 @@ fi
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
@@ -203,14 +206,15 @@ fi
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# 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
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
@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 Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@@ -75,10 +70,11 @@ goto fail
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
|
||||
@@ -29,10 +29,10 @@ extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
|
||||
}
|
||||
}
|
||||
expoAutolinking.useExpoModules()
|
||||
|
||||
rootProject.name = 'Sauci'
|
||||
|
||||
expoAutolinking.useExpoModules()
|
||||
expoAutolinking.useExpoVersionCatalog()
|
||||
|
||||
include ':app'
|
||||
|
||||
@@ -81,7 +81,7 @@ module.exports = () => ({
|
||||
// Keep this explicit because EOAS validates a primitive runtime string before
|
||||
// upload. The release version script updates it atomically with app.json and
|
||||
// the checked-in native runtime values.
|
||||
runtimeVersion: '1.0.8',
|
||||
runtimeVersion: '1.0.7',
|
||||
updates: {
|
||||
enabled: true,
|
||||
url: OTA_MANIFEST_URL,
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
"expo": {
|
||||
"name": "Sauci",
|
||||
"slug": "sauci",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.7",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"scheme": "app.sauci",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"platforms": [
|
||||
"ios",
|
||||
"android"
|
||||
],
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#1a1a2e"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.sauci.app",
|
||||
@@ -25,7 +27,7 @@
|
||||
"group.com.sauci.app"
|
||||
]
|
||||
},
|
||||
"buildNumber": "49",
|
||||
"buildNumber": "46",
|
||||
"usesAppleSignIn": true,
|
||||
"googleServicesFile": "./GoogleService-Info.plist",
|
||||
"infoPlist": {
|
||||
@@ -187,7 +189,7 @@
|
||||
"backgroundColor": "#1a1a2e"
|
||||
},
|
||||
"package": "com.sauci.app",
|
||||
"versionCode": 51,
|
||||
"versionCode": 49,
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"permissions": [
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
@@ -221,14 +223,6 @@
|
||||
},
|
||||
"plugins": [
|
||||
"./plugins/withFixHoisting.js",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#1a1a2e"
|
||||
}
|
||||
],
|
||||
"@react-native-firebase/app",
|
||||
[
|
||||
"expo-build-properties",
|
||||
@@ -326,10 +320,7 @@
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "807ca0af-e9bd-40e8-a658-f3382072c724",
|
||||
"observe": {
|
||||
"endpointUrl": "https://ota.apps.jbrunton.co.uk/observe/9a66f3c2-fda7-416f-87e2-6db833b32e9d"
|
||||
}
|
||||
"projectId": "807ca0af-e9bd-40e8-a658-f3382072c724"
|
||||
}
|
||||
},
|
||||
"owner": "jjbrunton-apps"
|
||||
|
||||
@@ -76,11 +76,10 @@ export default function AppLayout() {
|
||||
const segmentStrings = segments as string[];
|
||||
const isOnOnboarding = segmentStrings.includes("onboarding");
|
||||
const isOnPairing = segmentStrings.includes("pairing");
|
||||
const isOnPaired = segmentStrings.includes("paired");
|
||||
const isOnChat = segmentStrings.includes("chat");
|
||||
const isOnLiveDraw = segmentStrings.includes("live-draw");
|
||||
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
|
||||
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnPaired || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
|
||||
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
|
||||
|
||||
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
|
||||
useEffect(() => {
|
||||
@@ -368,7 +367,6 @@ export default function AppLayout() {
|
||||
href: null, // Hide from tab bar
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen name="paired" options={{ href: null }} />
|
||||
<Tabs.Screen
|
||||
name="chat/[id]"
|
||||
options={{
|
||||
|
||||
@@ -69,12 +69,10 @@ export default function MatchesScreen() {
|
||||
checkNudgeCooldown,
|
||||
} = useMatchStore();
|
||||
const { user, couple, partner } = useAuthStore();
|
||||
const userId = user?.id;
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
const [showTutorial, setShowTutorial] = useState(false);
|
||||
const tutorialDismissedRef = useRef(false);
|
||||
const [nudgeFeedback, setNudgeFeedback] = useState<string | null>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState(130);
|
||||
|
||||
@@ -106,30 +104,21 @@ export default function MatchesScreen() {
|
||||
// Check if tutorial should be shown when screen is focused or matches change
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let isActive = true;
|
||||
|
||||
const checkTutorial = async () => {
|
||||
if (matches.length > 0 && userId) {
|
||||
const seen = await hasSeenMatchesTutorial(userId);
|
||||
if (isActive && !tutorialDismissedRef.current && !seen) {
|
||||
if (matches.length > 0) {
|
||||
const seen = await hasSeenMatchesTutorial();
|
||||
if (!seen) {
|
||||
setShowTutorial(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
void checkTutorial();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [matches.length, userId])
|
||||
checkTutorial();
|
||||
}, [matches.length])
|
||||
);
|
||||
|
||||
const handleTutorialComplete = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
tutorialDismissedRef.current = true;
|
||||
await markMatchesTutorialSeen();
|
||||
setShowTutorial(false);
|
||||
await markMatchesTutorialSeen(userId);
|
||||
};
|
||||
|
||||
// Load whichever view is showing, once. Returning to this tab reuses what is
|
||||
|
||||
@@ -389,7 +389,7 @@ const styles = StyleSheet.create({
|
||||
zIndex: 100,
|
||||
},
|
||||
navBarBackground: {
|
||||
...StyleSheet.absoluteFill,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
backgroundColor: colors.background,
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
Platform,
|
||||
Keyboard,
|
||||
ScrollView,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeInUp,
|
||||
FadeOut,
|
||||
SlideInRight,
|
||||
SlideOutLeft,
|
||||
@@ -30,8 +32,8 @@ import { getProfileError } from '../../src/lib/errors';
|
||||
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
||||
import { Events } from '../../src/lib/analytics';
|
||||
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 { activeOnboardingStage, initialOnboardingStage, previousOnboardingStage, type OnboardingStage } from '../../src/lib/onboardingStages';
|
||||
import type { Gender, UsageReason } from '../../src/types';
|
||||
|
||||
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||
@@ -49,17 +51,15 @@ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
|
||||
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
|
||||
];
|
||||
|
||||
type Stage = OnboardingStage;
|
||||
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications';
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
// Only subscribe to stable values to prevent re-renders from background fetchUser calls
|
||||
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 initialUser = useRef(useAuthStore.getState().user);
|
||||
const [stage, setStage] = useState<Stage>(() => initialOnboardingStage(initialUser.current?.name));
|
||||
const [stage, setStage] = useState<Stage>('avatar');
|
||||
const [name, setName] = useState(initialUser.current?.name || '');
|
||||
const [gender, setGender] = useState<Gender | null>(initialUser.current?.gender || null);
|
||||
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
||||
@@ -69,16 +69,36 @@ export default function OnboardingScreen() {
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const paywallNavigationRef = useRef(false);
|
||||
|
||||
const pendingDisplayName = pendingAppleDisplayName?.userId === userId ? pendingAppleDisplayName?.name : undefined;
|
||||
const displayName = persistedUserName?.trim() || pendingDisplayName || name;
|
||||
const hasStoredDisplayName = Boolean(persistedUserName?.trim() || pendingDisplayName);
|
||||
const activeStage = activeOnboardingStage(stage, persistedUserName, pendingDisplayName);
|
||||
const previousStage = previousOnboardingStage(activeStage, hasStoredDisplayName);
|
||||
const {
|
||||
avatarUri,
|
||||
isUploading: isUploadingAvatar,
|
||||
showPicker: handleAvatarPress,
|
||||
uploadAvatar,
|
||||
} = useAvatarPicker({
|
||||
userId,
|
||||
onSelect: () => setError(null),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
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 = () => {
|
||||
if (!name.trim()) {
|
||||
setError('Please enter your name');
|
||||
@@ -134,7 +154,7 @@ export default function OnboardingScreen() {
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
console.log('[Onboarding] handleComplete called, user:', userId, 'name:', displayName);
|
||||
console.log('[Onboarding] handleComplete called, user:', userId, 'name:', name);
|
||||
if (!userId) {
|
||||
console.error('[Onboarding] No user ID available');
|
||||
setError('Not logged in. Please restart the app.');
|
||||
@@ -145,10 +165,19 @@ export default function OnboardingScreen() {
|
||||
setError(null);
|
||||
|
||||
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 updateData = {
|
||||
name: displayName.trim(),
|
||||
name: name.trim(),
|
||||
gender,
|
||||
usage_reason: usageReason,
|
||||
// Preserve the safest legacy profile values. Catalogue
|
||||
@@ -162,6 +191,10 @@ export default function OnboardingScreen() {
|
||||
|
||||
await profileSettingsApi.updateProfile(updateData);
|
||||
|
||||
if (avatarUrl) {
|
||||
updatedFields.push("avatar_url");
|
||||
}
|
||||
|
||||
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
||||
await fetchUser();
|
||||
console.log('[Onboarding] User fetched, navigating to home...');
|
||||
@@ -198,7 +231,71 @@ export default function OnboardingScreen() {
|
||||
};
|
||||
|
||||
const renderStage = () => {
|
||||
switch (activeStage) {
|
||||
switch (stage) {
|
||||
case 'avatar':
|
||||
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 (
|
||||
<Animated.View
|
||||
@@ -208,12 +305,16 @@ export default function OnboardingScreen() {
|
||||
style={styles.stageContainer}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="person" size={40} color={colors.text} />
|
||||
</LinearGradient>
|
||||
{avatarUri ? (
|
||||
<Image source={{ uri: avatarUri }} style={styles.headerAvatar} />
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="person" size={40} color={colors.text} />
|
||||
</LinearGradient>
|
||||
)}
|
||||
<Text style={styles.title}>What can we call you?</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Nicknames and pet names are welcome too!
|
||||
@@ -267,7 +368,7 @@ export default function OnboardingScreen() {
|
||||
style={styles.stageContainer}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.greeting}>Hey, {displayName.trim()}!</Text>
|
||||
<Text style={styles.greeting}>Hey, {name.trim()}!</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
This helps us show you the right questions
|
||||
</Text>
|
||||
@@ -475,12 +576,15 @@ export default function OnboardingScreen() {
|
||||
>
|
||||
{/* Header with back button and progress dots */}
|
||||
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
|
||||
{previousStage ? (
|
||||
{stage !== 'avatar' ? (
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
onPress={() => {
|
||||
setError(null);
|
||||
setStage(previousStage);
|
||||
if (stage === 'notifications') setStage('purpose');
|
||||
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} />
|
||||
@@ -489,10 +593,11 @@ export default function OnboardingScreen() {
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
)}
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[styles.progressDot, activeStage === 'name' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, activeStage === 'gender' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, activeStage === 'purpose' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, activeStage === 'notifications' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'avatar' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
|
||||
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
|
||||
</View>
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
</Animated.View>
|
||||
@@ -548,6 +653,18 @@ const styles = StyleSheet.create({
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -640,6 +757,47 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.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: {
|
||||
...typography.largeTitle,
|
||||
color: colors.text,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AccessibilityInfo, ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { GradientBackground, GlassButton } from "../../src/components/ui";
|
||||
import { colors, gradients, spacing, typography } from "../../src/theme";
|
||||
import { useAuthStore, useMatchStore } from "../../src/store";
|
||||
import { markPairedUnlockSeen } from "../../src/lib/pairedUnlockSeen";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
|
||||
export const POST_PAIR_ENTRY = "/(app)/swipe" as const;
|
||||
const bucket = (n: number) => n === 0 ? "0" : n <= 2 ? "1-2" : n <= 5 ? "3-5" : n <= 10 ? "6-10" : n <= 25 ? "11-25" : "26+";
|
||||
|
||||
export default function PairedScreen() {
|
||||
const { user, couple, partner } = useAuthStore();
|
||||
const { newMatchesCount, fetchMatches } = useMatchStore();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [loadedSuccessfully, setLoadedSuccessfully] = useState(false);
|
||||
const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>();
|
||||
const heading = useRef<Text>(null);
|
||||
const role = rawRole === "inviter" ? "inviter" : "joiner";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchMatches(true).then((success) => {
|
||||
if (cancelled) return;
|
||||
setLoadedSuccessfully(success);
|
||||
setReady(true);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [fetchMatches]);
|
||||
useEffect(() => {
|
||||
if (!ready || !loadedSuccessfully || !user || !couple) return;
|
||||
void markPairedUnlockSeen(user.id, couple.id);
|
||||
Events.pairingUnlockViewed(role, bucket(newMatchesCount));
|
||||
const timer = setTimeout(() => {
|
||||
if (heading.current) AccessibilityInfo.setAccessibilityFocus(heading.current as never);
|
||||
}, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [ready, loadedSuccessfully, user, couple, newMatchesCount]);
|
||||
|
||||
const partnerName = partner?.name?.split(" ")[0] || "your partner";
|
||||
const viewMatches = () => { Events.pairingUnlockCtaTapped(role, "view_matches"); router.replace("/(app)/matches"); };
|
||||
const start = () => { Events.pairingUnlockCtaTapped(role, "start_session"); router.replace(POST_PAIR_ENTRY); };
|
||||
const dismiss = () => { Events.pairingUnlockCtaTapped(role, "dismiss"); router.replace("/(app)"); };
|
||||
if (!ready) {
|
||||
return <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></View></GradientBackground>;
|
||||
}
|
||||
if (!loadedSuccessfully) {
|
||||
return <GradientBackground><View style={styles.container}><Text style={styles.title}>We couldn't load your shared discoveries</Text><Text style={styles.body}>Check your connection and try again.</Text><GlassButton fullWidth onPress={() => { setReady(false); void fetchMatches(true).then((success) => { setLoadedSuccessfully(success); setReady(true); }); }} testID="paired-retry">Try again</GlassButton></View></GradientBackground>;
|
||||
}
|
||||
return <GradientBackground><View style={styles.container}>
|
||||
<LinearGradient colors={gradients.primary as [string, string]} style={styles.icon}><Ionicons name="heart" size={42} color={colors.text} /></LinearGradient>
|
||||
<Text ref={heading} accessibilityRole="header" style={styles.title}>You're paired with {partnerName}</Text>
|
||||
{newMatchesCount > 0 ? <><Text style={styles.count}>{newMatchesCount} shared discover{newMatchesCount === 1 ? "y" : "ies"} ready to explore</Text><Text style={styles.body}>From answers you both gave before now.</Text><GlassButton fullWidth onPress={viewMatches} testID="paired-view-matches">See shared discoveries</GlassButton><GlassButton variant="secondary" fullWidth onPress={start} testID="paired-start-session">Start a session</GlassButton></> : <><Text style={styles.body}>Nothing is ready to compare yet. Answer a few questions and shared discoveries will appear here.</Text><GlassButton fullWidth onPress={start} testID="paired-start-session">Answer your first questions</GlassButton></>}
|
||||
<TouchableOpacity onPress={dismiss} testID="paired-not-now" style={styles.dismiss}><Text style={styles.dismissText}>Not now</Text></TouchableOpacity>
|
||||
</View></GradientBackground>;
|
||||
}
|
||||
const styles = StyleSheet.create({ container:{flex:1,justifyContent:"center",padding:spacing.xl,gap:spacing.lg,alignItems:"center"}, icon:{width:88,height:88,borderRadius:44,alignItems:"center",justifyContent:"center"}, title:{...typography.largeTitle,color:colors.text,textAlign:"center"}, count:{...typography.title2,color:colors.premium.rose,textAlign:"center"}, body:{...typography.callout,color:colors.textSecondary,textAlign:"center"}, dismiss:{minHeight:44,justifyContent:"center",paddingHorizontal:spacing.lg}, dismissText:{...typography.subhead,color:colors.textSecondary} });
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
AccessibilityInfo,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
@@ -28,44 +27,26 @@ import { colors, gradients, spacing, radius, typography, shadows } from "../../s
|
||||
import { isValidInviteCode, normalizeInviteCode } from "../../src/lib/inviteLink";
|
||||
import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pendingInviteCode";
|
||||
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
|
||||
import { ApiError } from "../../src/lib/apiClient";
|
||||
import { hasSeenPairedUnlock } from "../../src/lib/pairedUnlockSeen";
|
||||
import { PAIRING_PRIVACY_SUMMARY } from "../../src/lib/pairingPrivacyCopy";
|
||||
|
||||
export default function PairingScreen() {
|
||||
const { fetchCouple, fetchUser, couple, partner, sealedCount, user, isLoading: isAuthLoading } = useAuthStore();
|
||||
const params = useLocalSearchParams<{ code?: string; incomingCode?: string }>();
|
||||
const { fetchCouple, fetchUser, couple, partner, sealedCount, isLoading: isAuthLoading } = useAuthStore();
|
||||
const params = useLocalSearchParams<{ code?: string }>();
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [wasPrefilled, setWasPrefilled] = useState(false);
|
||||
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
|
||||
const [prefillAttempted, setPrefillAttempted] = useState(false);
|
||||
const [joinError, setJoinError] = useState<string | null>(null);
|
||||
const [isPollOffline, setIsPollOffline] = useState(false);
|
||||
const [isIncomingConflictDismissed, setIsIncomingConflictDismissed] = useState(false);
|
||||
|
||||
// Redirect if already paired. Pairing claims both members' sealed answers and
|
||||
// computes matches server-side, so the matches store must refetch here too:
|
||||
// whichever partner lands on this redirect first would otherwise show stale,
|
||||
// pre-pairing matches until some unrelated screen happened to refresh it.
|
||||
useEffect(() => {
|
||||
if (!couple || !partner || !user) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
if (typeof params.incomingCode === "string") {
|
||||
await clearPendingInviteCode(user.id);
|
||||
if (!cancelled) {
|
||||
Alert.alert("You're already paired", "That invite is for a different account. You're already paired with your partner.", [{ text: "OK", onPress: () => router.replace("/(app)") }]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const seen = await hasSeenPairedUnlock(user.id, couple.id);
|
||||
if (!cancelled) {
|
||||
router.replace(seen ? "/(app)" : "/(app)/paired" as any);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [couple, partner, user, params.incomingCode]);
|
||||
if (couple && partner) {
|
||||
void useMatchStore.getState().fetchMatches(true);
|
||||
router.replace("/(app)");
|
||||
}
|
||||
}, [couple, partner]);
|
||||
|
||||
// One-time prefill: apply a code carried by a join link (route param), then
|
||||
// a stashed code from before sign-in, and only otherwise offer a clipboard
|
||||
@@ -87,15 +68,15 @@ export default function PairingScreen() {
|
||||
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
|
||||
if (isValidInviteCode(routeCode)) {
|
||||
await applyPrefill(routeCode);
|
||||
await clearPendingInviteCode(user?.id);
|
||||
await clearPendingInviteCode();
|
||||
if (!cancelled) setPrefillAttempted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const stashedCode = user?.id ? await getPendingInviteCode(user.id) : null;
|
||||
const stashedCode = await getPendingInviteCode();
|
||||
if (stashedCode) {
|
||||
await applyPrefill(stashedCode);
|
||||
await clearPendingInviteCode(user?.id);
|
||||
await clearPendingInviteCode();
|
||||
if (!cancelled) setPrefillAttempted(true);
|
||||
return;
|
||||
}
|
||||
@@ -110,7 +91,7 @@ export default function PairingScreen() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [couple, params.code, prefillAttempted, user?.id]);
|
||||
}, [couple, params.code, prefillAttempted]);
|
||||
|
||||
const acceptClipboardOffer = () => {
|
||||
if (!clipboardOfferCode) return;
|
||||
@@ -132,7 +113,7 @@ export default function PairingScreen() {
|
||||
|
||||
// Poll every 5 seconds as fallback
|
||||
const pollInterval = setInterval(() => {
|
||||
void fetchCouple().then(() => setIsPollOffline(false)).catch(() => setIsPollOffline(true));
|
||||
fetchCouple();
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
@@ -159,12 +140,11 @@ export default function PairingScreen() {
|
||||
// Validate invite code format (8 alphanumeric characters)
|
||||
const sanitizedCode = inviteCode.trim().toUpperCase();
|
||||
if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) {
|
||||
setJoinError("That code isn't the right shape. Codes are 8 letters and numbers.");
|
||||
Alert.alert("Invalid Code", "Please enter a valid 8-character invite code.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setJoinError(null);
|
||||
try {
|
||||
await coupleApi.join(sanitizedCode);
|
||||
|
||||
@@ -172,26 +152,14 @@ export default function PairingScreen() {
|
||||
await fetchCouple(); // Fetch couple data
|
||||
// Joining claims both members' sealed answers and computes matches
|
||||
// server-side, so the joiner needs a fresh matches list too.
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
void useMatchStore.getState().fetchMatches(true);
|
||||
Events.coupleJoined();
|
||||
|
||||
router.replace("/(app)/paired" as any);
|
||||
Alert.alert("Success", "You are now paired!", [
|
||||
{ text: "Let's Go", onPress: () => router.replace("/(app)") }
|
||||
]);
|
||||
} catch (error: any) {
|
||||
const details = error instanceof ApiError ? error.details as { error?: { code?: string } } : undefined;
|
||||
const code = details?.error?.code;
|
||||
const message = code === "couple_full"
|
||||
? "That code is already in use by two people."
|
||||
: code === "already_paired"
|
||||
? "You're already paired."
|
||||
: code === "invalid_invite_code" && error.status === 404
|
||||
? "We can't find that code. It may have been cancelled. Ask them to send a new invite."
|
||||
: code === "invalid_invite_code"
|
||||
? "That code isn't the right shape. Codes are 8 letters and numbers."
|
||||
: /network|fetch|timeout/i.test(error?.message ?? "")
|
||||
? "Can't reach Sauci. Check your connection and try again."
|
||||
: getPairingError(error);
|
||||
setJoinError(message);
|
||||
AccessibilityInfo.announceForAccessibility(message);
|
||||
Alert.alert("Error", getPairingError(error));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -208,7 +176,6 @@ export default function PairingScreen() {
|
||||
const handleInviteCodeChange = (value: string) => {
|
||||
setWasPrefilled(false);
|
||||
setInviteCode(value);
|
||||
setJoinError(null);
|
||||
};
|
||||
|
||||
const shareMessage = couple?.invite_code
|
||||
@@ -243,57 +210,25 @@ export default function PairingScreen() {
|
||||
Events.codeShared();
|
||||
};
|
||||
|
||||
const completeJoinedInvite = async () => {
|
||||
await fetchUser();
|
||||
await fetchCouple();
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
router.replace("/(app)/paired" as any);
|
||||
};
|
||||
|
||||
const cancelInvite = async (afterCancel?: () => Promise<void>) => {
|
||||
try {
|
||||
await coupleApi.cancelInvite();
|
||||
await fetchUser();
|
||||
await fetchCouple();
|
||||
if (afterCancel) await afterCancel();
|
||||
Events.pairingCancelled();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
const apiCode = error instanceof ApiError
|
||||
? (error.details as { error?: { code?: string } } | undefined)?.error?.code
|
||||
: undefined;
|
||||
if (apiCode === "invite_already_joined") {
|
||||
await completeJoinedInvite();
|
||||
return false;
|
||||
}
|
||||
Alert.alert(
|
||||
"Couldn't cancel invite",
|
||||
"Your invite may still be active. Check your connection and try again.",
|
||||
[{ text: "Not now", style: "cancel" }, { text: "Try again", onPress: () => { void cancelInvite(afterCancel); } }],
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyInviteLink = async () => {
|
||||
if (couple?.invite_code) {
|
||||
await Clipboard.setStringAsync(`https://sauci.app/join/${couple.invite_code}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelPairing = async () => {
|
||||
Alert.alert(
|
||||
"Cancel this invite?",
|
||||
"Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.",
|
||||
"Cancel Pairing",
|
||||
"Are you sure you want to cancel? Your invite code will be deleted.",
|
||||
[
|
||||
{ text: "Keep invite", style: "cancel" },
|
||||
{ text: "Keep Waiting", style: "cancel" },
|
||||
{
|
||||
text: "Cancel invite",
|
||||
text: "Cancel Pairing",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await cancelInvite();
|
||||
await coupleApi.cancel();
|
||||
|
||||
await fetchUser();
|
||||
await fetchCouple();
|
||||
Events.pairingCancelled();
|
||||
} catch (error: any) {
|
||||
Alert.alert("Error", getPairingError(error));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -303,35 +238,6 @@ export default function PairingScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const incomingCode = typeof params.incomingCode === "string" && isValidInviteCode(normalizeInviteCode(params.incomingCode))
|
||||
? normalizeInviteCode(params.incomingCode)
|
||||
: null;
|
||||
|
||||
const handleCancelOwnAndPrefill = () => {
|
||||
if (!incomingCode) return;
|
||||
Alert.alert(
|
||||
"Cancel this invite?",
|
||||
"Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.",
|
||||
[
|
||||
{ text: "Keep my invite", style: "cancel", onPress: () => void clearPendingInviteCode(user?.id) },
|
||||
{
|
||||
text: "Cancel mine and join", style: "destructive", onPress: async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await cancelInvite(async () => {
|
||||
setInviteCode(incomingCode);
|
||||
setWasPrefilled(true);
|
||||
await clearPendingInviteCode(user?.id);
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
if (isAuthLoading) {
|
||||
return (
|
||||
<GradientBackground>
|
||||
@@ -359,22 +265,10 @@ export default function PairingScreen() {
|
||||
>
|
||||
<Ionicons name="arrow-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>Your invite</Text>
|
||||
<Text style={styles.title}>Partner Code</Text>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.content}>
|
||||
{incomingCode && !isIncomingConflictDismissed && (
|
||||
<GlassCard variant="elevated">
|
||||
<Text style={styles.conflictTitle}>Use this code instead?</Text>
|
||||
<Text style={styles.conflictBody}>You have your own invite out. To join theirs, cancel yours first. Your code will stop working, and answers you've given since you created it will be deleted.</Text>
|
||||
<GlassButton onPress={handleCancelOwnAndPrefill} variant="danger" fullWidth disabled={isSubmitting} testID="pairing-conflict-cancel-and-join">
|
||||
Cancel mine and join
|
||||
</GlassButton>
|
||||
<TouchableOpacity onPress={() => { setIsIncomingConflictDismissed(true); void clearPendingInviteCode(user?.id); }} style={styles.cancelButton} testID="pairing-conflict-keep-own">
|
||||
<Text style={styles.cancelButtonText}>Keep my invite</Text>
|
||||
</TouchableOpacity>
|
||||
</GlassCard>
|
||||
)}
|
||||
{/* Icon */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(200).duration(500)}
|
||||
@@ -389,7 +283,7 @@ export default function PairingScreen() {
|
||||
<Ionicons name="heart" size={40} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.subtitle}>
|
||||
Your answers are ready to compare when you both answer
|
||||
Share this code with your partner to link your accounts
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
@@ -401,7 +295,8 @@ export default function PairingScreen() {
|
||||
>
|
||||
<GlassCard variant="elevated">
|
||||
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
|
||||
You have saved at least {sealedCount} private question{sealedCount === 1 ? "" : "s"}. Vote-style questions show shared outcomes; open text, audio, photo, and who-likely responses can be visible to you both after you each answer.
|
||||
You have already answered {sealedCount} question{sealedCount === 1 ? "" : "s"} about us.
|
||||
They will unlock the moment your partner joins.
|
||||
</Text>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
@@ -418,12 +313,12 @@ export default function PairingScreen() {
|
||||
onPress={copyToClipboard}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.code} accessibilityLabel={`Invite code ${couple.invite_code.toUpperCase().split("").join(" ")}`}>{couple.invite_code.toUpperCase()}</Text>
|
||||
<Text style={styles.code}>{couple.invite_code.toUpperCase()}</Text>
|
||||
<View style={styles.copyIcon}>
|
||||
<Ionicons name="copy-outline" size={20} color={colors.textSecondary} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.tapToCopy}>Tap to copy your code</Text>
|
||||
<Text style={styles.tapToCopy}>Tap to copy</Text>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
@@ -446,7 +341,7 @@ export default function PairingScreen() {
|
||||
size="lg"
|
||||
icon={<Ionicons name="share-outline" size={22} color={colors.text} />}
|
||||
>
|
||||
Share invite
|
||||
Share Invite Code
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
|
||||
@@ -463,10 +358,6 @@ export default function PairingScreen() {
|
||||
<Ionicons name="logo-whatsapp" size={20} color={colors.text} />
|
||||
<Text style={styles.quickShareLabel}>WhatsApp</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.quickShareButton} onPress={copyInviteLink} activeOpacity={0.7} testID="pairing-copy-link" accessibilityLabel="Copy invite link">
|
||||
<Ionicons name="link-outline" size={20} color={colors.text} />
|
||||
<Text style={styles.quickShareLabel}>Copy link</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Waiting indicator */}
|
||||
@@ -476,9 +367,8 @@ export default function PairingScreen() {
|
||||
>
|
||||
<View style={styles.waitingBadge}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text style={styles.waitingText}>Waiting for them to join</Text>
|
||||
<Text style={styles.waitingText}>Waiting for your partner to join...</Text>
|
||||
</View>
|
||||
{isPollOffline && <Text style={styles.warningText} accessibilityLiveRegion="polite">Can't reach Sauci right now. Your code still works.</Text>}
|
||||
</Animated.View>
|
||||
|
||||
{/* Cancel Button */}
|
||||
@@ -492,7 +382,7 @@ export default function PairingScreen() {
|
||||
style={styles.cancelButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel invite</Text>
|
||||
<Text style={styles.cancelButtonText}>Cancel Pairing</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
@@ -537,7 +427,7 @@ export default function PairingScreen() {
|
||||
<Ionicons name="link" size={40} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.subtitle}>
|
||||
{PAIRING_PRIVACY_SUMMARY}
|
||||
Link with your partner to start matching
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
@@ -591,12 +481,7 @@ export default function PairingScreen() {
|
||||
/>
|
||||
{wasPrefilled && (
|
||||
<Text style={styles.prefilledCaption}>
|
||||
Code from your invite link
|
||||
</Text>
|
||||
)}
|
||||
{joinError && (
|
||||
<Text style={styles.joinError} accessibilityLiveRegion="polite" testID="pairing-join-error">
|
||||
{joinError}
|
||||
Code applied from your invite link
|
||||
</Text>
|
||||
)}
|
||||
<GlassButton
|
||||
@@ -605,7 +490,7 @@ export default function PairingScreen() {
|
||||
loading={isSubmitting}
|
||||
fullWidth
|
||||
>
|
||||
Join your partner
|
||||
Join Partner
|
||||
</GlassButton>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
@@ -631,7 +516,7 @@ export default function PairingScreen() {
|
||||
disabled={isSubmitting}
|
||||
fullWidth
|
||||
>
|
||||
Create invite code
|
||||
Create New Code
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
</View>
|
||||
@@ -838,28 +723,6 @@ const styles = StyleSheet.create({
|
||||
...typography.subhead,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
conflictTitle: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
conflictBody: {
|
||||
...typography.callout,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
warningText: {
|
||||
...typography.caption1,
|
||||
color: colors.warning,
|
||||
textAlign: "center",
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
joinError: {
|
||||
...typography.callout,
|
||||
color: colors.error,
|
||||
marginBottom: spacing.md,
|
||||
textAlign: "center",
|
||||
},
|
||||
cancelSection: {
|
||||
alignItems: "center",
|
||||
marginTop: spacing.xl,
|
||||
|
||||
@@ -46,11 +46,6 @@ if (Platform.OS === "android") {
|
||||
import { authClient } from "../../src/lib/authClient";
|
||||
import { getAuthError } from "../../src/lib/errors";
|
||||
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 { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
|
||||
import { colors, spacing, radius, typography } from "../../src/theme";
|
||||
@@ -295,7 +290,7 @@ export default function LoginScreen() {
|
||||
console.log("[Apple Sign In] Got credential:", !!credential, "identityToken:", !!credential?.identityToken);
|
||||
|
||||
if (credential.identityToken) {
|
||||
const { data, error } = await authClient.auth.signInWithIdToken({
|
||||
const { error } = await authClient.auth.signInWithIdToken({
|
||||
provider: "apple",
|
||||
token: credential.identityToken,
|
||||
});
|
||||
@@ -304,36 +299,6 @@ export default function LoginScreen() {
|
||||
console.log("[Apple Sign In] Supabase error:", error.message);
|
||||
Alert.alert("Error", getAuthError(error));
|
||||
} 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");
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -5,11 +5,6 @@ import "../src/polyfills/web";
|
||||
import { initCrashlytics, setUserContext, clearUserContext } from "../src/lib/crashlytics";
|
||||
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)
|
||||
import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics";
|
||||
|
||||
@@ -21,7 +16,7 @@ import {
|
||||
handleNotificationResponse,
|
||||
} from "../src/lib/notifications";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native";
|
||||
|
||||
// Suppress useLayoutEffect warning on server-side rendering
|
||||
@@ -38,7 +33,6 @@ if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
||||
}
|
||||
|
||||
import { Stack, router, usePathname } from "expo-router";
|
||||
import { ReduceMotion, ReducedMotionConfig } from "react-native-reanimated";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
// GestureHandlerRootView is only needed on native platforms
|
||||
@@ -51,7 +45,7 @@ import * as Linking from "expo-linking";
|
||||
import { useAuthStore } from "../src/store";
|
||||
import { authClient } from "../src/lib/authClient";
|
||||
import { colors, spacing, radius, typography } from "../src/theme";
|
||||
import { useGuestAccountWarning } from "../src/hooks/useGuestAccountWarning";
|
||||
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
|
||||
import { parseInviteLinkCode } from "../src/lib/inviteLink";
|
||||
import { stashPendingInviteCode } from "../src/lib/pendingInviteCode";
|
||||
import { needsOnboarding } from "../src/constants/onboarding";
|
||||
@@ -59,17 +53,18 @@ import { needsOnboarding } from "../src/constants/onboarding";
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function RootLayout() {
|
||||
const { fetchUser, setUser, user, isAuthenticated, isAnonymous } = useAuthStore();
|
||||
const { fetchUser, setUser, isAuthenticated, isAnonymous } = useAuthStore();
|
||||
const pathname = usePathname();
|
||||
const appState = useRef(AppState.currentState);
|
||||
const analyticsInitialized = useRef(false);
|
||||
|
||||
const { visible: showGuestWarning, dismiss: dismissGuestWarning } = useGuestAccountWarning({
|
||||
isAuthenticated,
|
||||
isAnonymous,
|
||||
userId: user?.id,
|
||||
pathname,
|
||||
});
|
||||
const [showGuestWarning, setShowGuestWarning] = useState(false);
|
||||
const [checkingGuestWarning, setCheckingGuestWarning] = useState(false);
|
||||
|
||||
const dismissGuestWarning = async () => {
|
||||
await markGuestAccountWarningSeen();
|
||||
setShowGuestWarning(false);
|
||||
};
|
||||
|
||||
|
||||
// Initialize analytics after native modules are ready
|
||||
@@ -78,7 +73,6 @@ export default function RootLayout() {
|
||||
analyticsInitialized.current = true;
|
||||
initAnalytics();
|
||||
Events.appOpened("cold");
|
||||
logAppStarted();
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -89,6 +83,22 @@ export default function RootLayout() {
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
// Warn guest users (first time only) that accounts are not recoverable
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !isAnonymous) return;
|
||||
if (checkingGuestWarning) return;
|
||||
if (pathname?.startsWith("/(auth)")) return;
|
||||
|
||||
setCheckingGuestWarning(true);
|
||||
hasSeenGuestAccountWarning()
|
||||
.then((seen) => {
|
||||
if (!seen) {
|
||||
setShowGuestWarning(true);
|
||||
}
|
||||
})
|
||||
.finally(() => setCheckingGuestWarning(false));
|
||||
}, [isAuthenticated, isAnonymous, pathname, checkingGuestWarning]);
|
||||
|
||||
// Track warm starts when app comes back from background
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
|
||||
@@ -114,18 +124,12 @@ export default function RootLayout() {
|
||||
const inviteLink = parseInviteLinkCode(url);
|
||||
if (inviteLink) {
|
||||
Events.inviteLinkOpened(inviteLink.source);
|
||||
await stashPendingInviteCode(inviteLink.code);
|
||||
|
||||
const { isAuthenticated, user } = useAuthStore.getState();
|
||||
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
|
||||
if (user?.couple_id) {
|
||||
await import("../src/lib/pendingInviteCode").then(({ clearPendingInviteCode }) => clearPendingInviteCode(user.id));
|
||||
if (user.couple_id) {
|
||||
router.push({ pathname: "/(app)/pairing", params: { incomingCode: inviteLink.code } });
|
||||
}
|
||||
} else {
|
||||
await stashPendingInviteCode(inviteLink.code, user?.id ?? null);
|
||||
if (isAuthenticated && !onboardingPending) {
|
||||
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
|
||||
}
|
||||
if (isAuthenticated && !onboardingPending && !user?.couple_id) {
|
||||
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -255,7 +259,6 @@ export default function RootLayout() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<ReducedMotionConfig mode={ReduceMotion.System} />
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
@@ -298,7 +301,8 @@ export default function RootLayout() {
|
||||
<TouchableOpacity
|
||||
style={styles.guestWarningPrimary}
|
||||
onPress={async () => {
|
||||
await dismissGuestWarning();
|
||||
await markGuestAccountWarningSeen();
|
||||
setShowGuestWarning(false);
|
||||
router.push("/(app)/settings/save-account" as any);
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
@@ -307,7 +311,10 @@ export default function RootLayout() {
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.guestWarningSecondary}
|
||||
onPress={dismissGuestWarning}
|
||||
onPress={async () => {
|
||||
await markGuestAccountWarningSeen();
|
||||
setShowGuestWarning(false);
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.guestWarningSecondaryText}>Not now</Text>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
|
||||
|
||||
export default function Index() {
|
||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||
const [pendingInviteCheck, setPendingInviteCheck] = useState<{ userId: string; hasInvite: boolean } | null>(null);
|
||||
const [pendingCodeChecked, setPendingCodeChecked] = useState(false);
|
||||
const [hasPendingInvite, setHasPendingInvite] = useState(false);
|
||||
|
||||
// Check once, after auth resolves, whether an invite code is waiting to be
|
||||
// applied (e.g. the user tapped a join link before signing in).
|
||||
@@ -17,17 +18,16 @@ export default function Index() {
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
if (!user?.id) return;
|
||||
setPendingInviteCheck(null);
|
||||
getPendingInviteCode(user.id).then((code) => {
|
||||
getPendingInviteCode().then((code) => {
|
||||
if (cancelled) return;
|
||||
setPendingInviteCheck({ userId: user.id, hasInvite: !!code });
|
||||
setHasPendingInvite(!!code);
|
||||
setPendingCodeChecked(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isLoading, isAuthenticated, user?.couple_id, user?.id]);
|
||||
}, [isLoading, isAuthenticated, user?.couple_id]);
|
||||
|
||||
// Show loading state while auth is being determined
|
||||
if (isLoading) {
|
||||
@@ -56,7 +56,7 @@ export default function Index() {
|
||||
// Wait for the one-time pending invite code check before deciding the
|
||||
// route, so a stashed code from a join link is applied without an extra
|
||||
// trip through the home screen.
|
||||
if (pendingInviteCheck?.userId !== user?.id) {
|
||||
if (!pendingCodeChecked) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ActivityIndicator size="large" color="#e94560" />
|
||||
@@ -64,7 +64,7 @@ export default function Index() {
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingInviteCheck?.hasInvite) {
|
||||
if (hasPendingInvite) {
|
||||
return <Redirect href="/(app)/pairing" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -425,7 +425,7 @@ const styles = StyleSheet.create({
|
||||
zIndex: 100,
|
||||
},
|
||||
navBarBackground: {
|
||||
...StyleSheet.absoluteFill,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
overflow: "hidden",
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
json_key_file(ENV["GOOGLE_PLAY_KEY_FILE"] || File.expand_path("../fastlane-key.json", __dir__))
|
||||
package_name("com.sauci.app")
|
||||
app_identifier("com.sauci.app")
|
||||
@@ -45,86 +45,4 @@ platform :android do
|
||||
lane :upload_beta do |options|
|
||||
upload(options.merge(track: "beta"))
|
||||
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
|
||||
@@ -28,6 +28,3 @@ project.xcworkspace
|
||||
|
||||
# CocoaPods
|
||||
/Pods/
|
||||
|
||||
# @bacons/apple-targets generated entitlements cache
|
||||
/.targets/
|
||||
@@ -1,28 +1,15 @@
|
||||
# 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('react-native/package.json')"`), "scripts/react_native_pods")
|
||||
|
||||
require 'json'
|
||||
podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {}
|
||||
|
||||
def ccache_enabled?(podfile_properties)
|
||||
# Environment variable takes precedence
|
||||
return ENV['USE_CCACHE'] == '1' if ENV['USE_CCACHE']
|
||||
ENV['RCT_NEW_ARCH_ENABLED'] = podfile_properties['newArchEnabled'] == 'true' ? '1' : '0'
|
||||
ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] = podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR']
|
||||
|
||||
# Fall back to Podfile properties
|
||||
podfile_properties['apple.ccacheEnabled'] == 'true'
|
||||
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'
|
||||
platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1'
|
||||
install! 'cocoapods',
|
||||
:deterministic_uuids => false
|
||||
|
||||
prepare_react_native_project!
|
||||
|
||||
@@ -36,8 +23,7 @@ target 'Sauci' do
|
||||
'node',
|
||||
'--no-warnings',
|
||||
'--eval',
|
||||
'require(\'expo/bin/autolinking\')',
|
||||
'expo-modules-autolinking',
|
||||
'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))',
|
||||
'react-native-config',
|
||||
'--json',
|
||||
'--platform',
|
||||
@@ -63,8 +49,34 @@ target 'Sauci' do
|
||||
installer,
|
||||
config[:reactNativePath],
|
||||
:mac_catalyst_enabled => false,
|
||||
:ccache_enabled => ccache_enabled?(podfile_properties),
|
||||
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"expo.jsEngine": "hermes",
|
||||
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
||||
"expo.inlineModules.watchedDirectories": "[]",
|
||||
"expo.inlineModules.xcodeProjectTargets": "{\"mainTarget\":\"Sauci\",\"targets\":[]}",
|
||||
"ios.forceStaticLinking": "[]",
|
||||
"apple.privacyManifestAggregationEnabled": "true",
|
||||
"EXPO_USE_PRECOMPILED_MODULES": "true",
|
||||
"ios.useFrameworks": "static"
|
||||
"newArchEnabled": "true",
|
||||
"ios.useFrameworks": "static",
|
||||
"apple.extraPods": "[]",
|
||||
"apple.ccacheEnabled": "false",
|
||||
"apple.privacyManifestAggregationEnabled": "true"
|
||||
}
|
||||
@@ -3,18 +3,19 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 46;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
3F202C2A859975F48A83ADD7 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 779267BD380A5D29590F1BDC /* ExpoModulesProvider.swift */; };
|
||||
45941E25D336B6152BFCEFB9 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B482C7D915C22B3F33CA9841 /* Pods_Sauci.framework */; };
|
||||
804CD03F6B584D08B7803C63 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 69E91809C21A4977A3249D53 /* PrivacyInfo.xcprivacy */; };
|
||||
5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = F744EAD4ABA54509966475D6 /* noop-file.swift */; };
|
||||
7B71EEB2AF682FA7AFF556D5 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */; };
|
||||
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
D60EEEF252214D45B67A0063 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = D58CE235EF5F40F58FB8111D /* GoogleService-Info.plist */; };
|
||||
CEF2F278ABBC5FBFD470A0F7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */; };
|
||||
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 */; };
|
||||
XX1A6800511A685AB346D2XX /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXA12889081EB894481655XX /* AppIntents.framework */; };
|
||||
XX3C562DEF346B45594832XX /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXCAF23C3FC41F82140FA1XX /* ActivityKit.framework */; };
|
||||
@@ -23,11 +24,11 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
XX77D941E1DB38490B8F4EXX /* PBXContainerItemProxy */ = {
|
||||
XXA7C7D5580082A497FD95XX /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = XXB3405CC208C2ECA4797FXX;
|
||||
remoteGlobalIDString = XX540FB4ABC75CE91A6036XX;
|
||||
remoteInfo = LiveDrawWidget;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
@@ -50,17 +51,18 @@
|
||||
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>"; };
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
|
||||
69E91809C21A4977A3249D53 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; 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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = undefined; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; 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; };
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Sauci/AppDelegate.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
XXA12889081EB894481655XX /* AppIntents.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; };
|
||||
@@ -69,21 +71,21 @@
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
XX6FB9E6678087E3AAC658XX /* Exceptions for "widget" folder in "LiveDrawWidget" target */ = {
|
||||
XX020388E3BB9C764E774FXX /* Exceptions for "widget" folder in "LiveDrawWidget" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
"expo-target.config.js",
|
||||
);
|
||||
target = XXB3405CC208C2ECA4797FXX /* LiveDrawWidget */;
|
||||
target = XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
XXBB44BF07EB68666150ABXX /* widget */ = {
|
||||
XX683E29AD7AD8279E17B2XX /* widget */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
XX6FB9E6678087E3AAC658XX /* Exceptions for "widget" folder in "LiveDrawWidget" target */,
|
||||
XX020388E3BB9C764E774FXX /* Exceptions for "widget" folder in "LiveDrawWidget" target */,
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
@@ -99,7 +101,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
45941E25D336B6152BFCEFB9 /* Pods_Sauci.framework in Frameworks */,
|
||||
7B71EEB2AF682FA7AFF556D5 /* Pods_Sauci.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -120,32 +122,15 @@
|
||||
13B07FAE1A68108700A75B9A /* Sauci */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||
F11748442D0722820044C1D9 /* Sauci-Bridging-Header.h */,
|
||||
BB2F792B24A3F905000567C9 /* Supporting */,
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||
69E91809C21A4977A3249D53 /* PrivacyInfo.xcprivacy */,
|
||||
D58CE235EF5F40F58FB8111D /* GoogleService-Info.plist */,
|
||||
);
|
||||
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 */,
|
||||
A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */,
|
||||
F744EAD4ABA54509966475D6 /* noop-file.swift */,
|
||||
DA536CAC88B349A59DFC1F1E /* Sauci-Bridging-Header.h */,
|
||||
D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
name = Sauci;
|
||||
sourceTree = "<group>";
|
||||
@@ -156,9 +141,9 @@
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||
XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */,
|
||||
XX4DFF38D47332D6BF0183XX /* SwiftUI.framework */,
|
||||
DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */,
|
||||
XXCAF23C3FC41F82140FA1XX /* ActivityKit.framework */,
|
||||
XXA12889081EB894481655XX /* AppIntents.framework */,
|
||||
B482C7D915C22B3F33CA9841 /* Pods_Sauci.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -178,8 +163,8 @@
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||
83CBBA001A601CBA00E9B192 /* Products */,
|
||||
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
||||
1957149DB83AAF8603C60859 /* Pods */,
|
||||
CC8FEB5C5178B9E5E71D4F61 /* ExpoModulesProviders */,
|
||||
D65327D7A22EEC0BE12398D9 /* Pods */,
|
||||
D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */,
|
||||
);
|
||||
indentWidth = 2;
|
||||
sourceTree = "<group>";
|
||||
@@ -195,6 +180,14 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
92DBD88DE9BF7D494EA9DA96 /* Sauci */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */,
|
||||
);
|
||||
name = Sauci;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BB2F792B24A3F905000567C9 /* Supporting */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -204,10 +197,19 @@
|
||||
path = Sauci/Supporting;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CC8FEB5C5178B9E5E71D4F61 /* ExpoModulesProviders */ = {
|
||||
D65327D7A22EEC0BE12398D9 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2BFCD64E7EEB594CC2D9E261 /* Sauci */,
|
||||
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */,
|
||||
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D7E4C46ADA2E9064B798F356 /* ExpoModulesProviders */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
92DBD88DE9BF7D494EA9DA96 /* Sauci */,
|
||||
);
|
||||
name = ExpoModulesProviders;
|
||||
sourceTree = "<group>";
|
||||
@@ -215,7 +217,7 @@
|
||||
XX3E41C1850246162C0061XX /* expo:targets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
XXBB44BF07EB68666150ABXX /* widget */,
|
||||
XX683E29AD7AD8279E17B2XX /* widget */,
|
||||
);
|
||||
name = "expo:targets";
|
||||
path = ../targets;
|
||||
@@ -229,31 +231,30 @@
|
||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
|
||||
buildPhases = (
|
||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
||||
E6F7433229E3C1448FC9F062 /* [Expo] Configure project */,
|
||||
DC3D179C6B62F16DA5F17C2F /* [Expo] Configure project */,
|
||||
13B07F871A680F5B00A75B9A /* Sources */,
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
||||
D58CF933D4804A0CB49C2891 /* [Expo Dev Launcher] Strip Local Network Keys for Release */,
|
||||
XX72A8CFBF0FBA13A79902XX /* Embed Foundation Extensions */,
|
||||
503A0FD3DBA862B4BE649BBB /* [CP] Embed Pods Frameworks */,
|
||||
8490E10253F91A7BEB80F555 /* [CP-User] [RNFB] Core Configuration */,
|
||||
0EE1B67C9AAD8BD8562E800E /* [CP-User] [RNFB] Crashlytics Configuration */,
|
||||
A1248897D24903117793599B /* [CP] Embed Pods Frameworks */,
|
||||
FB4B019D835055733ACDE109 /* [CP-User] [RNFB] Core Configuration */,
|
||||
563ACE2651AC39438F3B399B /* [CP-User] [RNFB] Crashlytics Configuration */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
XX5D3A21868E45E6E5BDC3XX /* PBXTargetDependency */,
|
||||
XX97289CEC7994AD5F4142XX /* PBXTargetDependency */,
|
||||
);
|
||||
name = Sauci;
|
||||
productName = Sauci;
|
||||
productReference = 13B07F961A680F5B00A75B9A /* Sauci.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
XXB3405CC208C2ECA4797FXX /* LiveDrawWidget */ = {
|
||||
XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = XX35D62F4FBF2C9086E8C9XX /* Build configuration list for PBXNativeTarget "LiveDrawWidget" */;
|
||||
buildConfigurationList = XX63FAB1BD532DA0D8AE18XX /* Build configuration list for PBXNativeTarget "LiveDrawWidget" */;
|
||||
buildPhases = (
|
||||
XX074B8811C1CF8E8A3D74XX /* Frameworks */,
|
||||
XXE45443DC38DCFE1DB622XX /* Sources */,
|
||||
@@ -264,7 +265,7 @@
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
XXBB44BF07EB68666150ABXX /* widget */,
|
||||
XX683E29AD7AD8279E17B2XX /* widget */,
|
||||
);
|
||||
name = LiveDrawWidget;
|
||||
productName = LiveDrawWidget;
|
||||
@@ -282,7 +283,7 @@
|
||||
13B07F861A680F5B00A75B9A = {
|
||||
LastSwiftMigration = 1250;
|
||||
};
|
||||
XXB3405CC208C2ECA4797FXX = {
|
||||
XX540FB4ABC75CE91A6036XX = {
|
||||
CreatedOnToolsVersion = 14.3;
|
||||
DevelopmentTeam = BXVPBATDZY;
|
||||
ProvisioningStyle = Automatic;
|
||||
@@ -303,7 +304,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
13B07F861A680F5B00A75B9A /* Sauci */,
|
||||
XXB3405CC208C2ECA4797FXX /* LiveDrawWidget */,
|
||||
XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -316,8 +317,8 @@
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||
804CD03F6B584D08B7803C63 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
D60EEEF252214D45B67A0063 /* GoogleService-Info.plist in Resources */,
|
||||
FDFABE1A1A3547B1B22DD2DD /* GoogleService-Info.plist in Resources */,
|
||||
CEF2F278ABBC5FBFD470A0F7 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -340,8 +341,6 @@
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/.xcode.env",
|
||||
"$(SRCROOT)/.xcode.env.local",
|
||||
);
|
||||
name = "Bundle React Native code and images";
|
||||
outputFileListPaths = (
|
||||
@@ -374,7 +373,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";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
0EE1B67C9AAD8BD8562E800E /* [CP-User] [RNFB] Crashlytics Configuration */ = {
|
||||
563ACE2651AC39438F3B399B /* [CP-User] [RNFB] Crashlytics Configuration */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -388,52 +387,6 @@
|
||||
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";
|
||||
};
|
||||
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 */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -445,10 +398,9 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXUpdates/EXUpdates.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_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}/FirebaseCoreExtension/FirebaseCoreExtension_Privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreInternal/FirebaseCoreInternal_Privacy.bundle",
|
||||
@@ -462,16 +414,18 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/PromisesSwift/Promises_Privacy.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}/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-cxxreact/React-cxxreact_privacy.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-menu/EXDevMenu.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_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";
|
||||
outputPaths = (
|
||||
@@ -479,10 +433,9 @@
|
||||
"${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}/ExpoConstants_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXUpdates.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_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}/ExpoNotifications_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}/FirebaseCoreInternal_Privacy.bundle",
|
||||
@@ -496,54 +449,43 @@
|
||||
"${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}/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}/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-cxxreact_privacy.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}/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}/RNViewShotPrivacyInfo.bundle",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
8490E10253F91A7BEB80F555 /* [CP-User] [RNFB] Core Configuration */ = {
|
||||
A1248897D24903117793599B /* [CP] Embed Pods Frameworks */ = {
|
||||
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
|
||||
};
|
||||
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 = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
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";
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
E6F7433229E3C1448FC9F062 /* [Expo] Configure project */ = {
|
||||
DC3D179C6B62F16DA5F17C2F /* [Expo] Configure project */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -567,6 +509,19 @@
|
||||
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";
|
||||
};
|
||||
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 */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -575,7 +530,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
3F202C2A859975F48A83ADD7 /* ExpoModulesProvider.swift in Sources */,
|
||||
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
|
||||
5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -589,17 +545,17 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
XX5D3A21868E45E6E5BDC3XX /* PBXTargetDependency */ = {
|
||||
XX97289CEC7994AD5F4142XX /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = XXB3405CC208C2ECA4797FXX /* LiveDrawWidget */;
|
||||
targetProxy = XX77D941E1DB38490B8F4EXX /* PBXContainerItemProxy */;
|
||||
target = XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */;
|
||||
targetProxy = XXA7C7D5580082A497FD95XX /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = FEACE2E5B4EA50AFBF4DDF34 /* Pods-Sauci.debug.xcconfig */;
|
||||
baseConfigurationReference = 6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
@@ -612,13 +568,9 @@
|
||||
"FB_SONARKIT_ENABLED=1",
|
||||
);
|
||||
INFOPLIST_FILE = Sauci/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.4;
|
||||
MARKETING_VERSION = 1.0.8;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.0.7;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -637,7 +589,7 @@
|
||||
};
|
||||
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = A172EA788EF476A7083810FE /* Pods-Sauci.release.xcconfig */;
|
||||
baseConfigurationReference = 7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
@@ -645,13 +597,9 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||
INFOPLIST_FILE = Sauci/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.4;
|
||||
MARKETING_VERSION = 1.0.8;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.0.7;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -716,39 +664,29 @@
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"${PODS_ROOT}/ReactCommon",
|
||||
"${PODS_ROOT}/ReactCommon/react/nativemodule/core",
|
||||
"${PODS_ROOT}/React-runtimeexecutor",
|
||||
"${PODS_ROOT}/React-runtimeexecutor/platform/ios",
|
||||
"${PODS_ROOT}/ReactCommon-Samples",
|
||||
"${PODS_ROOT}/ReactCommon-Samples/platform/ios",
|
||||
"${PODS_ROOT}/React-Fabric/react/renderer/components/view/platform/cxx",
|
||||
"${PODS_ROOT}/React-NativeModulesApple",
|
||||
"${PODS_ROOT}/React-graphics",
|
||||
"${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)",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers/platform/ios",
|
||||
);
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_CFLAGS = (
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-DRCT_REMOVE_LEGACY_ARCH=1",
|
||||
);
|
||||
OTHER_CPLUSPLUSFLAGS = (
|
||||
"$(inherited)",
|
||||
"-DRCT_REMOVE_LEGACY_ARCH=1",
|
||||
" ",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
};
|
||||
name = Debug;
|
||||
@@ -795,43 +733,33 @@
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"${PODS_ROOT}/ReactCommon",
|
||||
"${PODS_ROOT}/ReactCommon/react/nativemodule/core",
|
||||
"${PODS_ROOT}/React-runtimeexecutor",
|
||||
"${PODS_ROOT}/React-runtimeexecutor/platform/ios",
|
||||
"${PODS_ROOT}/ReactCommon-Samples",
|
||||
"${PODS_ROOT}/ReactCommon-Samples/platform/ios",
|
||||
"${PODS_ROOT}/React-Fabric/react/renderer/components/view/platform/cxx",
|
||||
"${PODS_ROOT}/React-NativeModulesApple",
|
||||
"${PODS_ROOT}/React-graphics",
|
||||
"${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)",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-runtimeexecutor/React_runtimeexecutor.framework/Headers/platform/ios",
|
||||
);
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
OTHER_CFLAGS = (
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-DRCT_REMOVE_LEGACY_ARCH=1",
|
||||
);
|
||||
OTHER_CPLUSPLUSFLAGS = (
|
||||
"$(inherited)",
|
||||
"-DRCT_REMOVE_LEGACY_ARCH=1",
|
||||
" ",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
XX0A0FB078256184BF7EA2XX /* Release */ = {
|
||||
XX1230635DC14DD95E28A2XX /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
@@ -844,7 +772,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
CURRENT_PROJECT_VERSION = 46;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -853,17 +781,12 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = LiveDrawWidget;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.8;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.0.7;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -873,7 +796,7 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
XX42CD38636C47E26CBE73XX /* Debug */ = {
|
||||
XX710DB962925BCE8D59ACXX /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
@@ -885,7 +808,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 49;
|
||||
CURRENT_PROJECT_VERSION = 46;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -894,18 +817,13 @@
|
||||
INFOPLIST_KEY_CFBundleDisplayName = LiveDrawWidget;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.8;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.0.7;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -936,11 +854,11 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
XX35D62F4FBF2C9086E8C9XX /* Build configuration list for PBXNativeTarget "LiveDrawWidget" */ = {
|
||||
XX63FAB1BD532DA0D8AE18XX /* Build configuration list for PBXNativeTarget "LiveDrawWidget" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
XX42CD38636C47E26CBE73XX /* Debug */,
|
||||
XX0A0FB078256184BF7EA2XX /* Release */,
|
||||
XX710DB962925BCE8D59ACXX /* Debug */,
|
||||
XX1230635DC14DD95E28A2XX /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
internal import Expo
|
||||
import Expo
|
||||
import FirebaseCore
|
||||
import React
|
||||
import ReactAppDependencyProvider
|
||||
|
||||
@main
|
||||
class AppDelegate: ExpoAppDelegate {
|
||||
@UIApplicationMain
|
||||
public class AppDelegate: ExpoAppDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
var reactNativeDelegate: ExpoReactNativeFactoryDelegate?
|
||||
@@ -20,12 +20,11 @@ class AppDelegate: ExpoAppDelegate {
|
||||
|
||||
reactNativeDelegate = delegate
|
||||
reactNativeFactory = factory
|
||||
bindReactNativeFactory(factory)
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
window = UIWindow(frame: UIScreen.main.bounds)
|
||||
// @generated begin @react-native-firebase/app-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-10e8520570672fd76b2403b7e1e27f5198a6349a
|
||||
FirebaseApp.configure()
|
||||
// @generated end @react-native-firebase/app-didFinishLaunchingWithOptions
|
||||
FirebaseApp.configure()
|
||||
factory.startReactNative(
|
||||
withModuleName: "main",
|
||||
in: window,
|
||||
@@ -35,39 +34,42 @@ FirebaseApp.configure()
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
// Linking API
|
||||
public override func application(
|
||||
_ app: UIApplication,
|
||||
open url: URL,
|
||||
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||
) -> Bool {
|
||||
return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options)
|
||||
super.application(app, open: url, options: options)
|
||||
|| RCTLinkingManager.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
// Universal Links
|
||||
public override func application(
|
||||
_ application: UIApplication,
|
||||
continue userActivity: NSUserActivity,
|
||||
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
|
||||
) -> Bool {
|
||||
let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result
|
||||
let result = RCTLinkingManager.application(
|
||||
application,
|
||||
continue: userActivity,
|
||||
restorationHandler: restorationHandler)
|
||||
return super.application(
|
||||
application,
|
||||
continue: userActivity,
|
||||
restorationHandler: restorationHandler) || result
|
||||
}
|
||||
}
|
||||
|
||||
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
|
||||
// Extension point for config-plugins
|
||||
|
||||
override func sourceURL(for bridge: RCTBridge) -> URL? {
|
||||
// needed to return the correct URL for expo-dev-client.
|
||||
bridge.bundleURL ?? bundleURL()
|
||||
}
|
||||
|
||||
override func bundleURL() -> URL? {
|
||||
#if DEBUG
|
||||
return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
|
||||
RCTBundleURLProvider.sharedSettings()
|
||||
.jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry")
|
||||
#else
|
||||
return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
|
||||
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 135 KiB |
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.8</string>
|
||||
<string>1.0.7</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
@@ -47,7 +47,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>49</string>
|
||||
<string>46</string>
|
||||
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
|
||||
<false/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
@@ -103,6 +103,13 @@
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Automatic</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
|
||||
@@ -24,14 +24,6 @@
|
||||
<string>C56D.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
@@ -41,171 +33,18 @@
|
||||
<string>85F4.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<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>
|
||||
<array/>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</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,12 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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">
|
||||
<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">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24053.1"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/>
|
||||
<capability name="Named colors" 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"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
@@ -18,13 +17,15 @@
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<subviews>
|
||||
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLogo" image="SplashScreenLogo" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
|
||||
<rect key="frame" x="146.5" y="376" width="100" height="100"/>
|
||||
<rect key="frame" x="0" y="0" width="414" height="736"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="Rmq-lb-GrQ"/>
|
||||
<constraints>
|
||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerX" secondItem="EXPO-ContainerView" secondAttribute="centerX" id="cad2ab56f97c5429bf29decf850647a4216861d4"/>
|
||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="centerY" secondItem="EXPO-ContainerView" secondAttribute="centerY" id="1a145271b085b6ce89b1405a310f5b1bb7656595"/>
|
||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="83fcb9b545b870ba44c24f0feeb116490c499c52"/>
|
||||
<constraint firstItem="EXPO-SplashScreen" firstAttribute="leading" secondItem="EXPO-ContainerView" secondAttribute="leading" id="61d16215e44b98e39d0a2c74fdbfaaa22601b12c"/>
|
||||
<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>
|
||||
<color key="backgroundColor" name="SplashScreenBackground"/>
|
||||
</view>
|
||||
@@ -35,10 +36,7 @@
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="SplashScreenLogo" width="100" height="100"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
<image name="SplashScreenLogo" width="414" height="736"/>
|
||||
<namedColor name="SplashScreenBackground">
|
||||
<color alpha="1.000" blue="0.180392156862745" green="0.101960784313725" red="0.101960784313725" customColorSpace="sRGB" colorSpace="custom"/>
|
||||
</namedColor>
|
||||
|
||||
@@ -31,8 +31,6 @@ Ug==
|
||||
<key>alg</key>
|
||||
<string>rsa-v1_5-sha256</string>
|
||||
</dict>
|
||||
<key>EXUpdatesEnableBsdiffPatchSupport</key>
|
||||
<true/>
|
||||
<key>EXUpdatesEnabled</key>
|
||||
<true/>
|
||||
<key>EXUpdatesLaunchWaitMs</key>
|
||||
@@ -47,7 +45,7 @@ Ug==
|
||||
<string/>
|
||||
</dict>
|
||||
<key>EXUpdatesRuntimeVersion</key>
|
||||
<string>1.0.8</string>
|
||||
<string>1.0.7</string>
|
||||
<key>EXUpdatesURL</key>
|
||||
<string>https://ota.apps.jbrunton.co.uk/manifest</string>
|
||||
</dict>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//
|
||||
// @generated
|
||||
// A blank Swift file must be created for native modules with Swift files to work correctly.
|
||||
//
|
||||
@@ -12,12 +12,6 @@ module.exports = {
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<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: [
|
||||
'<rootDir>/src/**/__tests__/**/*.(spec|test).(ts|tsx)',
|
||||
|
||||
@@ -19,11 +19,6 @@
|
||||
"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: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:production": "node scripts/ota-publish.mjs production --preflight",
|
||||
"ota:publish:staging": "node scripts/ota-publish.mjs staging",
|
||||
@@ -34,7 +29,7 @@
|
||||
"ota:doctor:production": "node scripts/ota-publish.mjs production --doctor"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bacons/apple-targets": "^5.0.0",
|
||||
"@bacons/apple-targets": "^0.1.6",
|
||||
"@bittingz/expo-widgets": "^3.0.2",
|
||||
"@expo/ngrok": "^4.1.0",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
@@ -44,55 +39,54 @@
|
||||
"@react-native-firebase/app": "^23.8.2",
|
||||
"@react-native-firebase/crashlytics": "^23.8.4",
|
||||
"@react-native-google-signin/google-signin": "^16.1.1",
|
||||
"@shopify/react-native-skia": "^2.6.9",
|
||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||
"@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",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"babel-preset-expo": "~56.0.0",
|
||||
"babel-preset-expo": "~54.0.12",
|
||||
"base64-arraybuffer": "^1.0.2",
|
||||
"expo": "^56.0.0",
|
||||
"expo-apple-authentication": "~56.0.4",
|
||||
"expo-asset": "~56.0.24",
|
||||
"expo-audio": "~56.0.13",
|
||||
"expo-blur": "~56.0.4",
|
||||
"expo-build-properties": "~56.0.27",
|
||||
"expo-clipboard": "~56.0.4",
|
||||
"expo-constants": "~56.0.25",
|
||||
"expo-dev-client": "~56.0.26",
|
||||
"expo-file-system": "~56.0.11",
|
||||
"expo-font": "~56.0.7",
|
||||
"expo-haptics": "~56.0.3",
|
||||
"expo-image": "~56.0.12",
|
||||
"expo-image-picker": "~56.0.25",
|
||||
"expo-linear-gradient": "~56.0.4",
|
||||
"expo-linking": "~56.0.17",
|
||||
"expo-local-authentication": "~56.0.5",
|
||||
"expo-media-library": "~56.0.11",
|
||||
"expo-notifications": "~56.0.25",
|
||||
"expo-observe": "~56.0.29",
|
||||
"expo-router": "~56.2.20",
|
||||
"expo-secure-store": "~56.0.4",
|
||||
"expo-sharing": "~56.0.26",
|
||||
"expo-splash-screen": "~56.0.15",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo-system-ui": "~56.0.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",
|
||||
"expo": "~54.0.0",
|
||||
"expo-apple-authentication": "~8.0.8",
|
||||
"expo-asset": "~12.0.13",
|
||||
"expo-av": "~16.0.8",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-constants": "~18.0.14",
|
||||
"expo-dev-client": "~6.0.21",
|
||||
"expo-file-system": "~19.0.24",
|
||||
"expo-font": "~14.0.12",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-picker": "~17.0.11",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-local-authentication": "~17.0.9",
|
||||
"expo-media-library": "~18.2.1",
|
||||
"expo-notifications": "~0.32.17",
|
||||
"expo-router": "~6.0.24",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-updates": "~29.0.20",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-android-widget": "^0.22.1",
|
||||
"react-native-compressor": "^1.13.0",
|
||||
"react-native-gesture-handler": "~2.31.1",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-purchases": "^9.6.12",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.26.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-share": "^12.3.1",
|
||||
"react-native-svg": "15.15.4",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-view-shot": "5.1.0",
|
||||
"react-native-view-shot": "^4.0.3",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.8.3",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -100,16 +94,15 @@
|
||||
"@testing-library/jest-native": "^5.4.3",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/react": "~19.2.14",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"eoas": "^3.1.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-expo": "~56.0.4",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~56.0.5",
|
||||
"react-test-renderer": "19.2.3",
|
||||
"typescript": "~6.0.3"
|
||||
"jest-expo": "~54.0.18",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
const requiredProductionRevenueCatConfig = [
|
||||
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'appl_'],
|
||||
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'goog_'],
|
||||
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', null],
|
||||
];
|
||||
|
||||
function fail(message) {
|
||||
console.error(`EOAS Expo export guard: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const [key, prefix] of requiredProductionRevenueCatConfig) {
|
||||
const value = process.env[key];
|
||||
const expected = process.env[`SAUCI_EOAS_EXPECTED_${key}`];
|
||||
if (typeof value !== 'string' || !value.trim()) fail(`${key} is required`);
|
||||
if (prefix && !value.startsWith(prefix)) fail(`${key} must be a valid RevenueCat public key`);
|
||||
if (value !== expected) fail(`${key} does not match the resolved production profile`);
|
||||
}
|
||||
|
||||
const npx = process.env.SAUCI_EOAS_NPX;
|
||||
if (!npx || !isAbsolute(npx)) fail('missing resolved npx executable');
|
||||
|
||||
const result = spawnSync(npx, process.argv.slice(2), { env: process.env, stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { accessSync, constants, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { delimiter, join, resolve } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
@@ -27,13 +27,6 @@ const requiredPublicKeys = [
|
||||
'EXPO_PUBLIC_SUPABASE_URL',
|
||||
'EXPO_PUBLIC_SUPABASE_ANON_KEY',
|
||||
];
|
||||
const requiredProductionRevenueCatConfig = [
|
||||
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'appl_'],
|
||||
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'goog_'],
|
||||
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', null],
|
||||
];
|
||||
const EOAS_PACKAGE_RUNNER = 'sauci-eoas-npx';
|
||||
const EOAS_RUNNER_BIN_DIR = join(projectDir, 'scripts', 'bin');
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(`OTA publish preflight: ${message}`);
|
||||
@@ -74,87 +67,9 @@ export function resolvePublishEnvironment(target, environment = process.env, con
|
||||
requireCanonicalHttpsRoot(env.EXPO_PUBLIC_SUPABASE_URL, profile.authOrigin, `${target} Auth URL`);
|
||||
requireCanonicalHttpsRoot(env.EXPO_PUBLIC_API_URL, profile.apiOrigin, `${target} API URL`);
|
||||
|
||||
if (target === 'production') {
|
||||
for (const [key, prefix] of requiredProductionRevenueCatConfig) {
|
||||
const value = env[key];
|
||||
if (typeof value !== 'string' || !value.trim()) fail(`${key} is required for production export`);
|
||||
if (prefix && !value.startsWith(prefix)) {
|
||||
fail(`${key} must be a valid ${prefix} RevenueCat public key`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { branch: profile.branch, env };
|
||||
}
|
||||
|
||||
function exportFiles(outputDir) {
|
||||
return readdirSync(outputDir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const entryPath = join(outputDir, entry.name);
|
||||
return entry.isDirectory() ? exportFiles(entryPath) : [entryPath];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Expo inlines EXPO_PUBLIC_* values into the JavaScript bundle. Verify the
|
||||
* isolated production export rather than trusting only the process environment:
|
||||
* a missing key otherwise becomes an empty string on installed devices.
|
||||
*/
|
||||
export function assertProductionExportContainsRevenueCatConfig(outputDir, environment) {
|
||||
const files = exportFiles(outputDir).filter((filePath) => statSync(filePath).isFile());
|
||||
for (const [key] of requiredProductionRevenueCatConfig) {
|
||||
const expectedValue = environment[key];
|
||||
const present = files.some((filePath) => readFileSync(filePath).includes(expectedValue));
|
||||
if (!present) fail(`production export does not contain ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExecutable(command, pathValue) {
|
||||
for (const directory of pathValue.split(delimiter)) {
|
||||
if (!directory) continue;
|
||||
const candidate = join(directory, command);
|
||||
try {
|
||||
accessSync(candidate, constants.X_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Continue looking through PATH.
|
||||
}
|
||||
}
|
||||
fail(`could not resolve ${command} before configuring the EOAS package runner`);
|
||||
}
|
||||
|
||||
/**
|
||||
* EOAS starts its own Expo subprocess with EXPO_NO_DOTENV=1. Pin its package
|
||||
* runner to our checked-in wrapper so the actual EOAS export fails before it
|
||||
* starts if its inherited RevenueCat configuration differs from this profile.
|
||||
*/
|
||||
export function buildEoasEnvironment(environment) {
|
||||
const originalPath = environment.PATH || process.env.PATH || '';
|
||||
const expected = Object.fromEntries(requiredProductionRevenueCatConfig.map(([key]) => [
|
||||
`SAUCI_EOAS_EXPECTED_${key}`,
|
||||
environment[key],
|
||||
]));
|
||||
return {
|
||||
...environment,
|
||||
EXPO_NO_DOTENV: '1',
|
||||
EOAS_PACKAGE_RUNNER,
|
||||
PATH: `${EOAS_RUNNER_BIN_DIR}${delimiter}${originalPath}`,
|
||||
SAUCI_EOAS_NPX: resolveExecutable('npx', originalPath),
|
||||
...expected,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEoasPublishArgs(branch, rawPublishArgs, useProductionGuard = false) {
|
||||
const args = [
|
||||
'eoas',
|
||||
'publish',
|
||||
'--branch',
|
||||
branch,
|
||||
...parsePublishArgs(rawPublishArgs),
|
||||
];
|
||||
if (useProductionGuard) args.splice(4, 0, '--packageRunner', EOAS_PACKAGE_RUNNER);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function parsePublishArgs(args) {
|
||||
const parsed = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
@@ -197,18 +112,6 @@ function run(command, args, env) {
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
function exportAndVerify(target, environment) {
|
||||
const outputDir = mkdtempSync(join(tmpdir(), `sauci-ota-${target}-`));
|
||||
try {
|
||||
run('npx', ['expo', 'export', '--output-dir', outputDir], environment);
|
||||
if (target === 'production') {
|
||||
assertProductionExportContainsRevenueCatConfig(outputDir, environment);
|
||||
}
|
||||
} finally {
|
||||
rmSync(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function main(args = process.argv.slice(2), environment = process.env) {
|
||||
const [target, ...remaining] = args;
|
||||
const preflight = remaining.includes('--preflight');
|
||||
@@ -219,7 +122,12 @@ export function main(args = process.argv.slice(2), environment = process.env) {
|
||||
const { branch, env } = resolvePublishEnvironment(target, environment);
|
||||
const exportEnv = { ...env, EXPO_NO_DOTENV: '1' };
|
||||
if (preflight) {
|
||||
exportAndVerify(target, exportEnv);
|
||||
const outputDir = mkdtempSync(join(tmpdir(), `sauci-ota-${target}-`));
|
||||
try {
|
||||
run('npx', ['expo', 'export', '--output-dir', outputDir], exportEnv);
|
||||
} finally {
|
||||
rmSync(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
console.log(`OTA ${target} export preflight passed`);
|
||||
return;
|
||||
}
|
||||
@@ -230,13 +138,10 @@ export function main(args = process.argv.slice(2), environment = process.env) {
|
||||
return;
|
||||
}
|
||||
|
||||
exportAndVerify(target, exportEnv);
|
||||
const eoasEnvironment = target === 'production' ? buildEoasEnvironment(exportEnv) : exportEnv;
|
||||
run('npx', buildEoasPublishArgs(branch, rawPublishArgs, target === 'production'), eoasEnvironment);
|
||||
run('npx', ['eoas', 'publish', '--branch', branch, ...parsePublishArgs(rawPublishArgs)], exportEnv);
|
||||
}
|
||||
|
||||
// Compare real filesystem paths. A URL pathname percent-encodes spaces, so
|
||||
// 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.
|
||||
// whose path contains a space.
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|
||||
@@ -1,17 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
assertProductionExportContainsRevenueCatConfig,
|
||||
buildEoasEnvironment,
|
||||
buildEoasPublishArgs,
|
||||
parsePublishArgs,
|
||||
resolvePublishEnvironment,
|
||||
} from './ota-publish.mjs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parsePublishArgs, resolvePublishEnvironment } from './ota-publish.mjs';
|
||||
|
||||
const config = {
|
||||
build: {
|
||||
@@ -20,9 +9,6 @@ const config = {
|
||||
EXPO_PUBLIC_API_URL: 'https://api.sauci.app',
|
||||
EXPO_PUBLIC_SUPABASE_URL: 'https://ckjcrkjpmhqhiucifukx.supabase.co',
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY: 'production-anon-key',
|
||||
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY: 'appl_test_ios_public_key',
|
||||
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY: 'goog_test_android_public_key',
|
||||
EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID: 'Sauci Pro',
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
@@ -42,79 +28,6 @@ test('production export uses the checked-in production profile', () => {
|
||||
assert.equal(env.EXPO_PUBLIC_API_URL, 'https://api.sauci.app');
|
||||
});
|
||||
|
||||
test('production export fails closed without valid RevenueCat public configuration', () => {
|
||||
for (const [key, value] of [
|
||||
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', ''],
|
||||
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'goog_wrong_platform_key'],
|
||||
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', ''],
|
||||
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'appl_wrong_platform_key'],
|
||||
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', ''],
|
||||
]) {
|
||||
const productionEnv = { ...config.build.production.env, [key]: value };
|
||||
assert.throws(
|
||||
() => resolvePublishEnvironment('production', {}, {
|
||||
build: { ...config.build, production: { env: productionEnv } },
|
||||
}),
|
||||
new RegExp(key),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('production preflight proves the compiled bundle contains RevenueCat configuration', () => {
|
||||
const outputDir = mkdtempSync(join(tmpdir(), 'sauci-ota-export-test-'));
|
||||
const environment = config.build.production.env;
|
||||
try {
|
||||
writeFileSync(
|
||||
join(outputDir, 'index.js'),
|
||||
[
|
||||
environment.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY,
|
||||
environment.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY,
|
||||
environment.EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID,
|
||||
].join(','),
|
||||
);
|
||||
assert.doesNotThrow(() => assertProductionExportContainsRevenueCatConfig(outputDir, environment));
|
||||
|
||||
writeFileSync(join(outputDir, 'index.js'), environment.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY);
|
||||
assert.throws(
|
||||
() => assertProductionExportContainsRevenueCatConfig(outputDir, environment),
|
||||
/EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('EOAS uses a guarded package runner that receives the resolved production environment', () => {
|
||||
const eoasEnvironment = buildEoasEnvironment({ ...config.build.production.env, PATH: process.env.PATH });
|
||||
assert.equal(eoasEnvironment.EOAS_PACKAGE_RUNNER, 'sauci-eoas-npx');
|
||||
assert.match(eoasEnvironment.PATH, /apps\/mobile\/scripts\/bin/);
|
||||
assert.equal(eoasEnvironment.SAUCI_EOAS_EXPECTED_EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID, 'Sauci Pro');
|
||||
|
||||
const runnerPath = fileURLToPath(new URL('./bin/sauci-eoas-npx', import.meta.url));
|
||||
const successful = spawnSync(process.execPath, [runnerPath, 'expo', 'export'], {
|
||||
env: { ...eoasEnvironment, SAUCI_EOAS_NPX: '/usr/bin/true' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(successful.status, 0, successful.stderr);
|
||||
|
||||
const missingKey = spawnSync(process.execPath, [runnerPath, 'expo', 'export'], {
|
||||
env: { ...eoasEnvironment, EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY: '', SAUCI_EOAS_NPX: '/usr/bin/true' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(missingKey.status, 1);
|
||||
assert.match(missingKey.stderr, /EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY/);
|
||||
assert.doesNotMatch(missingKey.stderr, /goog_test_android_public_key/);
|
||||
|
||||
assert.deepEqual(
|
||||
buildEoasPublishArgs('production', ['--platform', 'all'], true),
|
||||
['eoas', 'publish', '--branch', 'production', '--packageRunner', 'sauci-eoas-npx', '--platform', 'all'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildEoasPublishArgs('staging', ['--platform', 'ios']),
|
||||
['eoas', 'publish', '--branch', 'staging', '--platform', 'ios'],
|
||||
);
|
||||
});
|
||||
|
||||
test('staging export requires the designated non-production Auth configuration', () => {
|
||||
const environment = {
|
||||
EXPO_PUBLIC_SUPABASE_URL: 'https://itbzhrvlgvdmzbnhzhyx.supabase.co',
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/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();
|
||||
@@ -1,129 +0,0 @@
|
||||
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 }));
|
||||
});
|
||||