For exactly two people

A private space
for two.

One-to-one chat. A shared calendar. Backed by a hosted Supabase project on the free tier — no personal server to leave running, no ads, no analytics, no third-party tracking.

the two of us
morning ☕08:14
morning! big day today08:16
yeah — exam at 10. wish me luck08:17
you've got this. i'll be there after08:18
message…
NOV 2026
Thu 14 · 10:00
exam
shared
Sat 16 · 19:30
dinner at ours
private

Small by design.

A one-to-one chat, a shared calendar, and a synchronisation model built to survive a network drop. No feature you didn't ask for.

01 / Chat

One-to-one, honestly.

Messages have three states: pending, sent, failed. No read receipts, no delivery theatre — a successful push ticket is not proof the other phone displayed anything.

02 / Calendar

Shared, or private.

Events are shared by default, editable by either partner. Private events are private at the database level, not just hidden in the UI. Row-level security enforces it.

03 / Sync

Local-first, server-authoritative.

SQLite is the source of truth on-device. Writes go through an idempotent outbox. Realtime is a hint, never a transport. Conflicts resolve by comparing updated_at, never from the event payload.

Written. Not yet verified.

The source, migrations, RLS policies, and tests are complete. They have not been run against a live Supabase project or a physical device from the environment this repository was assembled in. Here is exactly what that means.

Written & reviewed 7 items
Database migrations, RLS policies, grants, SECURITY DEFINER helpers
pgTAP test suites for membership, invitations, messages, and events
Local SQLite schema, migrator, repositories, outbox, sync engine
Jest tests for backoff, conflict resolution, date/DST handling, error redaction
All screens, the design system, light and dark themes
Edge Function notify-partner for push notifications
Pairing flow: 24-byte invitation code, hash-only storage, 48-hour expiry, revocable
Not yet verified 6 items
supabase test db — requires a linked live project or a local Docker stack
npm test — requires npm install to complete
tsc --noEmit — same
EAS build — requires an Expo account and FCM credentials
SQLCipher on device — must be confirmed on the built APK
Two-device end-to-end flow — requires two physical phones
Show the 19-step verification procedure
  1. Install the APK on both phones.→ App launches to sign-in.
  2. Phone A: sign up, create space, generate code.→ A long code is shown once.
  3. Phone B: sign up, redeem the code.→ Both phones show the same empty chat.
  4. Phone B: redeem the same code again, new account.→ Rejected: already used, or space full.
  5. Phone A sends "hello".→ Appears immediately as pending, then sent.
  6. Phone B, foregrounded.→ Message appears within a few seconds.
  7. Phone B replies.→ Phone A receives it.
  8. Phone A creates an event for tomorrow.→ Visible on both calendars.
  9. Phone B edits the event title.→ Phone A shows the new title.
  10. Phone A creates a private event.→ Phone B never sees it, even after sync.
  11. Phone A, airplane mode, send "offline".→ Appears with a pending clock icon.
  12. Phone A, still offline, edit an event.→ Saved locally, marked waiting.
  13. Phone A, disable airplane mode.→ Message and event sync — no duplicates.
  14. Kill both apps, reopen.→ History and events are still there.
  15. Phone B: uninstall, reinstall, sign in.→ History re-downloads.
  16. Phone B backgrounded; Phone A sends a message.→ Generic "New message" push — no text in the notification.
  17. Tap the notification.→ Opens the chat.
  18. Set an event reminder 10 minutes out, close the app.→ Local notification fires with no network.
  19. Third account attempts to read the couple via the API.→ Returns zero rows — not an error, not data.

Security, honestly.

Read this first

End-to-end encryption is not implemented. The Supabase project operator, and anyone with database or administrative access, can technically read message content. This is stated in the app's Settings screen, and here.

What is implemented

  • TLS on every network request
  • Encryption at rest, by the database provider
  • SQLCipher on device — a lost phone does not expose a plaintext chat database
  • Strict RLS on every table, with grants reviewed separately from policies
  • All writes go through SECURITY DEFINER RPCs that re-validate membership
  • No message content in push payloads, logs, or crash reports
  • No analytics SDKs, no advertising SDKs, no third-party tracking
  • The service_role key is never in the app bundle

What is not implemented

  • End-to-end encryption. Server-side operators can read messages.
  • Password reset. No reset email is configured in this build.
  • Account transfer. Losing the account loses the space.
  • Audit logging. No admin access trail is exposed to either partner.
  • Signed updates. The APK is not signed by a Play-verified key.

Why both grants and policies are checked

They are two independent gates. A grant decides whether a role may touch the table at all — failure raises 42501 before any policy runs. A policy decides which rows. Adding a policy never revokes an existing grant, and new tables in public can arrive with anon privileges already granted. Every migration therefore revokes and re-grants explicitly, then enables RLS.

Why a SECURITY DEFINER helper exists

A policy on messages that reads couple_members, plus a policy on couple_members that reads couples, produces 42P17 infinite recursion detected in policy for relation and every query fails for both partners. public.is_couple_member(uuid) breaks the cycle by running as postgres, which has BYPASSRLS. It is declared with set search_path = '', fully schema-qualified names, and execute granted only to authenticated. Those are security requirements, not style.

Free, as long as the free tier holds.

Every figure below is a hard limit that returns errors or pauses the project. Zero cost is a current property of the free tiers, not a guarantee — provider terms, quotas, and inactivity policies change.

Resource Free limit Your needs
Supabase database 500 MB ~1–2M short messages
Supabase egress 5 GB / month Text payloads only
Supabase Edge Functions 500,000 / month A few hundred messages a day
Supabase Realtime 200 concurrent 2
Supabase monthly active users 50,000 2
Expo EAS Build 15 Android / month Enough to ship, tight during iteration
Expo Push notifications Free, 600/sec Plenty
Inactivity pause After 7 days idle The one real risk
The 7-day pause

The only free-tier behaviour likely to bite. If neither partner opens the app for a week, the project pauses and wakes on the next request — a latency blip, not data loss. Optional mitigation, at zero cost, entirely inside Supabase:

select cron.schedule('keepalive','0 3 */5 * *',$$ select 1 from public.couples limit 1; $$);

Do not add an external uptime monitor for this. It introduces an account and a dependency for a problem the database can solve itself.

Sideloading an APK needs no Play account. iOS distribution, a public Play Store listing, and going above 500 MB / 2 projects are the only paths to cost — none of them are triggered by this app.

Get it running on both phones.

Requires Node.js 20+, Android Studio (for a local build) or an Expo account (for an EAS cloud build), and two physical Android phones. Push notifications do not work on emulators.

01

Clone the repository

git clone https://github.com/mirconegri/two-couple-app.git
02

Create the backend

Create a Supabase project at supabase.com/dashboard (free plan, no card required). Then link, push, and test:

supabase link --project-ref <your-project-ref>
supabase db push && supabase test db

Do not continue until supabase test db passes. It runs four pgTAP suites that assert a third account sees nothing, that an expired or reused invitation is refused, that a client cannot insert directly into messages, and that a partner cannot modify a private event.

03

Configure the app

Copy .env.example to .env and fill in from Project Settings → API:

EXPO_PUBLIC_SUPABASE_URL=https://<ref>.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=<anon key>

Use the anon / publishable key. Never the service_role key — it bypasses RLS.

04

Build the installable APK

Push notifications require a development or preview build. Expo Go does not support remote push from SDK 53 onward. Generate an FCM V1 service account key from a Firebase project, upload it with eas credentials, then:

eas build --profile preview --platform android
05

Pair the two phones

Phone A: create an account, tap Create our space, tap Generate invitation code. Send the code to Phone B. Phone B: create an account, tap I have an invitation, paste the code, join.

06

Enable message notifications

Deploy the Edge Function and create the webhook:

supabase functions deploy notify-partner --no-verify-jwt

In Dashboard → Database → Webhooks, add: table messages, event INSERT, type Supabase Edge Function, function notify-partner. The platform injects SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY automatically.

What it doesn't do.

Deliberate scope boundaries and platform constraints. Not bugs waiting to be discovered.

01

No end-to-end encryption. The operator can read messages. Stated in the app's Settings screen and in the security section above.

02

No password reset. Supabase's reset flow requires configuring email delivery — deliberately out of scope for v1. Keep the password safe.

03

No account transfer. Losing the account loses the space. This is the deliberate tradeoff between easy recovery and keeping the space restricted to two people.

04

No recurring events. Correct recurrence with exceptions and per-instance time zones is a project in itself. v1 prioritises reliability.

05

Text-only chat. No attachments, voice notes, reactions, typing indicators, or read receipts.

06

No event editing UI. Create and delete are implemented. The RPC supports editing and a partner may edit shared events through it, but there is no edit screen yet. This is the most visible gap.

07

7-day inactivity pause unless the optional pg_cron keepalive is enabled.

08

Push is best-effort. Aggressive Android battery optimisation (Xiaomi, Huawei, Samsung) can delay delivery. dontkillmyapp.com is the relevant reference.

09

No iOS build. Android only, as specified.

Built in the open,
one message at a time.

Look at the code, run the verification procedure, or fork it for your own two people.