From 348ef221dbc3324739d32744e3c046e9679e9824 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Fri, 2 Jan 2026 21:36:26 +0000 Subject: [PATCH] Improve E2EE robustness and Android permissions - Add IV validation and key sanitization for encryption - Add stale key repair for media decryption failures - Block READ_MEDIA_IMAGES/VIDEO permissions (use photo picker) - Prevent double-tap crashes in media picker - Clear media cache on logout - Persist versionCode/buildNumber in app.json - Bump Android targetSdk to 35 --- app.json | 10 - apps/mobile/android/app/build.gradle | 2 +- apps/mobile/android/build.gradle | 2 +- apps/mobile/app.json | 13 + .../src/features/chat/hooks/useMediaUpload.ts | 15 +- apps/mobile/src/hooks/useDecryptedMedia.ts | 80 ++- apps/mobile/src/hooks/useEncryptedSend.ts | 15 +- apps/mobile/src/hooks/useMediaPicker.ts | 167 ++++-- .../src/lib/encryption/mediaDecryption.ts | 13 +- .../src/lib/encryption/messageDecryption.ts | 15 +- apps/mobile/src/store/authStore.ts | 8 + docs/frontend-optimization-plan.md | 522 ++++++++++++++++++ eas.json | 21 - 13 files changed, 777 insertions(+), 106 deletions(-) delete mode 100644 app.json create mode 100644 docs/frontend-optimization-plan.md delete mode 100644 eas.json diff --git a/app.json b/app.json deleted file mode 100644 index 743a26a..0000000 --- a/app.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "expo": { - "extra": { - "eas": { - "projectId": "807ca0af-e9bd-40e8-a658-f3382072c724" - } - }, - "owner": "jjbrunton-apps" - } -} diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 417f16f..71f896b 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -93,7 +93,7 @@ android { applicationId 'com.sauci.app' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 + versionCode 9 versionName "1.0.0" } signingConfigs { diff --git a/apps/mobile/android/build.gradle b/apps/mobile/android/build.gradle index 47ef030..f6acd14 100644 --- a/apps/mobile/android/build.gradle +++ b/apps/mobile/android/build.gradle @@ -5,7 +5,7 @@ buildscript { buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0' minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24') compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35') - targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34') + targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '35') kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25' ndkVersion = "26.1.10909125" diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 3d39390..f995d73 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -16,6 +16,7 @@ "ios": { "supportsTablet": false, "bundleIdentifier": "com.sauci.app", + "buildNumber": "5", "usesAppleSignIn": true, "infoPlist": { "UIBackgroundModes": [ @@ -30,6 +31,7 @@ "backgroundColor": "#1a1a2e" }, "package": "com.sauci.app", + "versionCode": 10, "googleServicesFile": "./google-services.json", "permissions": [ "RECEIVE_BOOT_COMPLETED", @@ -45,6 +47,17 @@ "favicon": "./assets/favicon.png" }, "plugins": [ + [ + "expo-build-properties", + { + "android": { + "blockedPermissions": [ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.READ_MEDIA_VIDEO" + ] + } + } + ], "expo-router", "expo-apple-authentication", [ diff --git a/apps/mobile/src/features/chat/hooks/useMediaUpload.ts b/apps/mobile/src/features/chat/hooks/useMediaUpload.ts index 4d09912..10ecd14 100644 --- a/apps/mobile/src/features/chat/hooks/useMediaUpload.ts +++ b/apps/mobile/src/features/chat/hooks/useMediaUpload.ts @@ -13,6 +13,8 @@ import { getAdminPublicKey, getAdminKeyId, MESSAGE_VERSION_E2EE, + isValidPublicKeyJwk, + sanitizePublicKeyJwk, } from '../../../lib/encryption'; import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption'; import { useEncryptionKeys } from '../../../hooks'; @@ -80,7 +82,18 @@ export function useMediaUpload(matchId: string, userId: string | undefined) { const adminPublicKey = await getAdminPublicKey(); const adminKeyId = await getAdminKeyId(); - const partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined; + let partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined; + + // Validate and sanitize partner's public key + if (partnerPublicKey) { + if (!isValidPublicKeyJwk(partnerPublicKey)) { + console.warn('[E2EE Media Upload] Partner public key is invalid, treating as unavailable'); + partnerPublicKey = null; + } else { + // Sanitize to remove trailing dots from base64 fields + partnerPublicKey = sanitizePublicKeyJwk(partnerPublicKey); + } + } console.log('[E2EE Media Upload] Partner public key available:', !!partnerPublicKey); diff --git a/apps/mobile/src/hooks/useDecryptedMedia.ts b/apps/mobile/src/hooks/useDecryptedMedia.ts index 68660ba..3935df4 100644 --- a/apps/mobile/src/hooks/useDecryptedMedia.ts +++ b/apps/mobile/src/hooks/useDecryptedMedia.ts @@ -8,7 +8,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { Platform } from 'react-native'; import * as FileSystem from 'expo-file-system'; -import { decryptMediaFile, cleanupDecryptedMedia, MESSAGE_VERSION_E2EE } from '../lib/encryption'; +import { decryptMediaFile, cleanupDecryptedMedia, MESSAGE_VERSION_E2EE, repairStaleKey } from '../lib/encryption'; import type { KeysMetadata, RSAPrivateKeyJWK } from '../lib/encryption'; import { useEncryptionKeys } from './useEncryptionKeys'; import { getCachedSignedUrl, getStoragePath } from '../lib/imageCache'; @@ -278,7 +278,83 @@ export function useDecryptedMedia({ }); } } catch (err) { - console.error(`[E2EE Media] Message ${messageId}: Decryption failed:`, err); + const error = err instanceof Error ? err : new Error('Unknown error'); + console.error(`[E2EE Media] Message ${messageId}: Decryption failed:`, error); + + // Check if this is a stale key failure (message encrypted with old key) + const isStaleKeyFailure = + error.name === 'OperationError' || + error.message.includes('OperationError') || + error.message.includes('decryption failed') || + error.message.includes('decrypt error') || + error.message.includes('error in DoCipher'); + + // Attempt stale key repair if not already attempted + if (isStaleKeyFailure && !repairAttemptedByMessageId.current.has(messageId)) { + console.log(`[E2EE Media] Message ${messageId}: Attempting stale key repair...`); + + if (isMounted.current) { + setState({ + uri: null, + isDecrypting: true, + error: null, + errorCode: undefined, + }); + } + + repairAttemptedByMessageId.current.add(messageId); + + try { + const repairResult = await repairStaleKey(messageId); + + if (repairResult.success) { + console.log(`[E2EE Media] Message ${messageId}: Stale key repaired, retrying decryption...`); + + // Fetch updated message and retry + const { data: refreshed } = await supabase + .from('messages') + .select('keys_metadata') + .eq('id', messageId) + .maybeSingle(); + + const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null; + + if (refreshedMetadata && encryptionIv && mediaPath) { + const storagePath = getStoragePath(mediaPath); + const encryptedUrl = await getCachedSignedUrl(storagePath); + + if (encryptedUrl) { + const decryptedUri = await decryptMediaFile( + encryptedUrl, + encryptionIv, + refreshedMetadata, + privateKeyJwk as RSAPrivateKeyJWK, + !isMe, + mediaType, + ); + + const cacheKey = `${messageId}-${mediaPath}`; + decryptedMediaCache.set(cacheKey, decryptedUri); + + if (isMounted.current) { + setState({ + uri: decryptedUri, + isDecrypting: false, + error: null, + errorCode: undefined, + }); + } + return; + } + } + } else { + console.error(`[E2EE Media] Message ${messageId}: Stale key repair failed:`, repairResult.error); + } + } catch (repairErr) { + console.error(`[E2EE Media] Message ${messageId}: Stale key repair threw:`, repairErr); + } + } + if (isMounted.current) { setState({ uri: null, diff --git a/apps/mobile/src/hooks/useEncryptedSend.ts b/apps/mobile/src/hooks/useEncryptedSend.ts index f30ae4c..e0b6d19 100644 --- a/apps/mobile/src/hooks/useEncryptedSend.ts +++ b/apps/mobile/src/hooks/useEncryptedSend.ts @@ -14,6 +14,8 @@ import { encryptTextMessage, getAdminPublicKey, getAdminKeyId, + isValidPublicKeyJwk, + sanitizePublicKeyJwk, } from '../lib/encryption'; import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption'; import { useEncryptionKeys } from './useEncryptionKeys'; @@ -87,11 +89,22 @@ export function useEncryptedSend(): UseEncryptedSendReturn { const adminKeyId = await getAdminKeyId(); // Get partner's public key from the freshly fetched profile - const partnerPublicKey = freshPartner?.public_key_jwk as + let partnerPublicKey = freshPartner?.public_key_jwk as | RSAPublicKeyJWK | null | undefined; + // Validate and sanitize partner's public key + if (partnerPublicKey) { + if (!isValidPublicKeyJwk(partnerPublicKey)) { + console.warn('[E2EE Send] Partner public key is invalid, treating as unavailable'); + partnerPublicKey = null; + } else { + // Sanitize to remove trailing dots from base64 fields (react-native-quick-crypto bug) + partnerPublicKey = sanitizePublicKeyJwk(partnerPublicKey); + } + } + console.log('[E2EE Send] Partner public key available:', !!partnerPublicKey); // Encrypt the message using the key from global store diff --git a/apps/mobile/src/hooks/useMediaPicker.ts b/apps/mobile/src/hooks/useMediaPicker.ts index e2623b5..2cb5bf7 100644 --- a/apps/mobile/src/hooks/useMediaPicker.ts +++ b/apps/mobile/src/hooks/useMediaPicker.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useRef } from 'react'; import { Alert, Platform } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; @@ -40,6 +40,10 @@ const DEFAULT_CONFIG: Required = { */ export const useMediaPicker = (config?: MediaPickerConfig) => { const settings = { ...DEFAULT_CONFIG, ...config }; + + // Track if a picker is currently active to prevent double-presentation crashes + // This is common on iOS if the user double-taps or if an action fires twice + const isPresentingRef = useRef(false); /** * Request camera permissions with user-friendly error handling. @@ -62,26 +66,37 @@ export const useMediaPicker = (config?: MediaPickerConfig) => { * @returns MediaResult if selected, null if cancelled */ const pickMedia = useCallback(async (): Promise => { - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.All, - quality: settings.imageQuality, - videoMaxDuration: settings.libraryVideoMaxDuration, - }); + if (isPresentingRef.current) return null; + isPresentingRef.current = true; - if (result.canceled) { - return null; + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.All, + quality: settings.imageQuality, + videoMaxDuration: settings.libraryVideoMaxDuration, + }); + + if (result.canceled) { + return null; + } + + const asset = result.assets[0]; + const isVideo = asset.type === 'video'; + + return { + uri: asset.uri, + mediaType: isVideo ? 'video' : 'image', + width: asset.width, + height: asset.height, + duration: asset.duration ?? undefined, + }; + } finally { + // Add a small delay to ensure the modal dismissal animation completes + // before allowing another presentation + setTimeout(() => { + isPresentingRef.current = false; + }, 500); } - - const asset = result.assets[0]; - const isVideo = asset.type === 'video'; - - return { - uri: asset.uri, - mediaType: isVideo ? 'video' : 'image', - width: asset.width, - height: asset.height, - duration: asset.duration ?? undefined, - }; }, [settings.imageQuality, settings.libraryVideoMaxDuration]); /** @@ -89,26 +104,43 @@ export const useMediaPicker = (config?: MediaPickerConfig) => { * @returns MediaResult if captured, null if cancelled or permission denied */ const takePhoto = useCallback(async (): Promise => { - const hasPermission = await requestCameraPermission(); - if (!hasPermission) { - return null; + if (isPresentingRef.current) return null; + isPresentingRef.current = true; + + try { + const hasPermission = await requestCameraPermission(); + if (!hasPermission) { + isPresentingRef.current = false; + return null; + } + + const result = await ImagePicker.launchCameraAsync({ + quality: settings.imageQuality, + }); + + if (result.canceled) { + return null; + } + + const asset = result.assets[0]; + return { + uri: asset.uri, + mediaType: 'image', + width: asset.width, + height: asset.height, + }; + } catch (error) { + // Ensure we reset state on error + isPresentingRef.current = false; + throw error; + } finally { + // Only reset if we didn't reset in the permission check + if (isPresentingRef.current) { + setTimeout(() => { + isPresentingRef.current = false; + }, 500); + } } - - const result = await ImagePicker.launchCameraAsync({ - quality: settings.imageQuality, - }); - - if (result.canceled) { - return null; - } - - const asset = result.assets[0]; - return { - uri: asset.uri, - mediaType: 'image', - width: asset.width, - height: asset.height, - }; }, [settings.imageQuality, requestCameraPermission]); /** @@ -116,29 +148,44 @@ export const useMediaPicker = (config?: MediaPickerConfig) => { * @returns MediaResult if recorded, null if cancelled or permission denied */ const recordVideo = useCallback(async (): Promise => { - const hasPermission = await requestCameraPermission(); - if (!hasPermission) { - return null; + if (isPresentingRef.current) return null; + isPresentingRef.current = true; + + try { + const hasPermission = await requestCameraPermission(); + if (!hasPermission) { + isPresentingRef.current = false; + return null; + } + + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Videos, + quality: settings.imageQuality, + videoMaxDuration: settings.cameraVideoMaxDuration, + }); + + if (result.canceled) { + return null; + } + + const asset = result.assets[0]; + return { + uri: asset.uri, + mediaType: 'video', + width: asset.width, + height: asset.height, + duration: asset.duration ?? undefined, + }; + } catch (error) { + isPresentingRef.current = false; + throw error; + } finally { + if (isPresentingRef.current) { + setTimeout(() => { + isPresentingRef.current = false; + }, 500); + } } - - const result = await ImagePicker.launchCameraAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Videos, - quality: settings.imageQuality, - videoMaxDuration: settings.cameraVideoMaxDuration, - }); - - if (result.canceled) { - return null; - } - - const asset = result.assets[0]; - return { - uri: asset.uri, - mediaType: 'video', - width: asset.width, - height: asset.height, - duration: asset.duration ?? undefined, - }; }, [settings.imageQuality, settings.cameraVideoMaxDuration, requestCameraPermission]); return { diff --git a/apps/mobile/src/lib/encryption/mediaDecryption.ts b/apps/mobile/src/lib/encryption/mediaDecryption.ts index 3a57522..4c15584 100644 --- a/apps/mobile/src/lib/encryption/mediaDecryption.ts +++ b/apps/mobile/src/lib/encryption/mediaDecryption.ts @@ -49,7 +49,13 @@ export async function decryptMediaFile( ); const encryptedData = base64ToArrayBuffer(encryptedBase64); - // 3. Unwrap AES key + // 3. Validate and decode IV (must be exactly 12 bytes for AES-GCM) + const iv = base64ToUint8Array(encryptionIv); + if (iv.length !== 12) { + throw new Error(`Invalid IV length: expected 12 bytes, got ${iv.length}`); + } + + // 4. Unwrap AES key const wrappedKeyBase64 = isRecipient ? keysMetadata.recipient_wrapped_key : keysMetadata.sender_wrapped_key; @@ -61,11 +67,10 @@ export async function decryptMediaFile( const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk); const aesKey = await importAESKey(rawAesKey); - // 4. Decrypt file - const iv = base64ToUint8Array(encryptionIv); + // 5. Decrypt file const decryptedData = await decryptAES(encryptedData, aesKey, iv); - // 5. Write decrypted file + // 6. Write decrypted file const ext = mediaType === 'video' ? 'mp4' : 'jpg'; const decryptedUri = `${FileSystem.cacheDirectory}${Date.now()}_decrypted.${ext}`; diff --git a/apps/mobile/src/lib/encryption/messageDecryption.ts b/apps/mobile/src/lib/encryption/messageDecryption.ts index d70d375..e52831b 100644 --- a/apps/mobile/src/lib/encryption/messageDecryption.ts +++ b/apps/mobile/src/lib/encryption/messageDecryption.ts @@ -35,18 +35,23 @@ export async function decryptTextMessage( throw new Error('No wrapped key available for decryption'); } - // 2. Unwrap AES key using RSA private key + // 2. Validate and decode IV (must be exactly 12 bytes for AES-GCM) + const iv = base64ToUint8Array(encryptionIv); + if (iv.length !== 12) { + throw new Error(`Invalid IV length: expected 12 bytes, got ${iv.length}`); + } + + // 3. Unwrap AES key using RSA private key const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk); - // 3. Import AES key + // 4. Import AES key const aesKey = await importAESKey(rawAesKey); - // 4. Decrypt content - const iv = base64ToUint8Array(encryptionIv); + // 5. Decrypt content const ciphertext = base64ToArrayBuffer(encryptedContent); const plaintext = await decryptAES(ciphertext, aesKey, iv); - // 5. Decode to string + // 6. Decode to string return new TextDecoder().decode(plaintext); } diff --git a/apps/mobile/src/store/authStore.ts b/apps/mobile/src/store/authStore.ts index 532d3bf..26220c4 100644 --- a/apps/mobile/src/store/authStore.ts +++ b/apps/mobile/src/store/authStore.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { supabase } from "../lib/supabase"; import { Events } from "../lib/analytics"; import { clearKeys } from "../lib/encryption"; +import { clearDecryptedMediaCache } from "../hooks/useDecryptedMedia"; import type { Profile, Couple } from "@/types"; import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from "../lib/encryption/types"; @@ -181,6 +182,13 @@ export const useAuthStore = create((set, get) => ({ console.error("Failed to clear encryption keys:", error); } + // Clear decrypted media cache to free storage + try { + await clearDecryptedMediaCache(); + } catch (error) { + console.error("Failed to clear media cache:", error); + } + // Clear local state FIRST to ensure UI updates even if Supabase call fails set({ user: null, diff --git a/docs/frontend-optimization-plan.md b/docs/frontend-optimization-plan.md new file mode 100644 index 0000000..759cb11 --- /dev/null +++ b/docs/frontend-optimization-plan.md @@ -0,0 +1,522 @@ +# Frontend-Backend Communication Optimization Plan + +This document outlines identified chattiness issues in the Sauci mobile app and provides a prioritized implementation plan for optimizations. + +## Executive Summary + +**Estimated Extra Traffic:** ~40-50% of current API calls are redundant or suboptimal + +**Key Problem Areas:** +- Partner profile refreshed on every message send +- Match details fetched from DB instead of using cached store data +- Redundant polling alongside real-time subscriptions +- N+1 query patterns in match/response fetching +- Missing caching strategies across stores + +--- + +## High Priority Tasks + +### Task 1: Cache Partner Public Key with TTL + +**Impact:** Eliminates 1 database query per message sent + +**Current Behavior:** +- `useEncryptedSend.ts:84-85` calls `refreshPartner()` on every text message +- `useMediaUpload.ts:80-81` calls `refreshPartner()` on every media upload +- No caching - always hits database + +**Files to Modify:** +- `apps/mobile/src/store/authStore.ts` +- `apps/mobile/src/hooks/useEncryptedSend.ts` +- `apps/mobile/src/features/chat/hooks/useMediaUpload.ts` + +**Implementation:** +```typescript +// In authStore.ts - add to state +partnerLastFetched: number | null; + +// In refreshPartner action +refreshPartner: async () => { + const now = Date.now(); + const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + + const { partner, partnerLastFetched } = get(); + + // Return early if cache is fresh and partner has public key + if (partner?.public_key_jwk && + partnerLastFetched && + now - partnerLastFetched < CACHE_TTL) { + return; + } + + // ... existing fetch logic + set({ partner: data, partnerLastFetched: now }); +} +``` + +**In hooks - add force refresh option:** +```typescript +// Only force refresh if encryption fails due to key mismatch +const sendEncrypted = async (content: string, forceKeyRefresh = false) => { + if (forceKeyRefresh) { + await refreshPartner(); + } + // ... rest of logic +} +``` + +**Acceptance Criteria:** +- [ ] Partner profile cached for 5 minutes +- [ ] Cache bypassed if `public_key_jwk` is null +- [ ] Force refresh available for encryption failures +- [ ] No regression in E2EE functionality + +--- + +### Task 2: Use Store Data for Message Notifications + +**Impact:** Eliminates 1 database query per incoming message + +**Current Behavior:** +- `_layout.tsx:462-466` fetches match details from DB for every incoming message +- Match data already exists in `matchStore` + +**Files to Modify:** +- `apps/mobile/app/(app)/_layout.tsx` + +**Implementation:** +```typescript +// Replace DB query with store lookup +const handleNewMessage = async (newMessage: Message) => { + // Try to get match from store first + const { matches } = useMatchStore.getState(); + const existingMatch = matches.find(m => m.id === newMessage.match_id); + + if (existingMatch) { + // Use cached data + showNotificationToast(existingMatch.question.text, newMessage); + } else { + // Fallback: fetch from DB only if not in store (rare edge case) + const { data: match } = await supabase + .from("matches") + .select("id, question:questions(text)") + .eq("id", newMessage.match_id) + .single(); + + if (match) { + showNotificationToast(match.question.text, newMessage); + // Also refresh matches store since we're missing data + fetchMatches(); + } + } +}; +``` + +**Acceptance Criteria:** +- [ ] Message notifications use store data when available +- [ ] Fallback to DB query only when match not in store +- [ ] Store refreshed if match is missing (data consistency) + +--- + +### Task 3: Remove Redundant Polling in Pairing Screen + +**Impact:** Eliminates polling every 5 seconds when waiting for partner + +**Current Behavior:** +- `pairing.tsx:39-45` polls `fetchCouple()` every 5 seconds +- `pairing.tsx:47-71` also subscribes to real-time profile updates +- Both doing the same job - redundant + +**Files to Modify:** +- `apps/mobile/app/(app)/pairing.tsx` + +**Implementation:** +```typescript +// DELETE the polling interval (lines 39-45) +// KEEP only the real-time subscription + +useEffect(() => { + if (!couple || partner) return; + + // Real-time subscription handles partner joining + const subscription = supabase + .channel(`couple-${couple.id}`) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "public", + table: "profiles", + filter: `couple_id=eq.${couple.id}`, + }, + (payload) => { + if (payload.new.id !== user?.id) { + fetchCouple(); // Partner joined + } + } + ) + .subscribe(); + + return () => { + subscription.unsubscribe(); + }; +}, [couple, partner, user?.id, fetchCouple]); +``` + +**Acceptance Criteria:** +- [ ] Polling removed +- [ ] Real-time subscription still detects partner joining +- [ ] No increase in time to detect partner + +--- + +## Medium Priority Tasks + +### Task 4: Optimize Match Responses Query (N+1 Fix) + +**Impact:** Reduces 2 queries to 1, eliminates client-side filtering + +**Current Behavior:** +- `matchStore.ts:23-29` fetches all matches +- `matchStore.ts:35-38` fetches ALL responses separately +- Client-side filtering to match responses to questions + +**Files to Modify:** +- `apps/mobile/src/store/matchStore.ts` + +**Implementation:** +```typescript +// Option A: Join responses in single query +const fetchMatches = async () => { + const { data: matches } = await supabase + .from("matches") + .select(` + *, + question:questions(*), + user_response:responses!inner( + id, answer, user_id + ) + `) + .eq("couple_id", coupleId) + .in("responses.user_id", [userId, partnerId]) + .order("created_at", { ascending: false }); + + // Process matches with embedded responses + const processedMatches = matches?.map(match => ({ + ...match, + userAnswer: match.user_response.find(r => r.user_id === userId)?.answer, + partnerAnswer: match.user_response.find(r => r.user_id === partnerId)?.answer, + })); + + set({ matches: processedMatches }); +}; +``` + +**Alternative - Database View:** +```sql +-- Create view that joins match with responses +CREATE VIEW match_with_responses AS +SELECT + m.*, + q.text as question_text, + q.intensity, + ur.answer as user_answer, + pr.answer as partner_answer +FROM matches m +JOIN questions q ON m.question_id = q.id +LEFT JOIN responses ur ON m.question_id = ur.question_id AND ur.user_id = m.user1_id +LEFT JOIN responses pr ON m.question_id = pr.question_id AND pr.user_id = m.user2_id; +``` + +**Acceptance Criteria:** +- [ ] Single query fetches matches with responses +- [ ] No client-side filtering of responses +- [ ] Match list still displays correctly + +--- + +### Task 5: Fix Mark All As Seen Trigger + +**Impact:** Eliminates unnecessary DB updates on every render + +**Current Behavior:** +- `matches.tsx:66-69` calls `markAllAsSeen()` whenever `matches.length` changes +- Triggers even when navigating back to screen with same matches + +**Files to Modify:** +- `apps/mobile/app/(app)/matches.tsx` + +**Implementation:** +```typescript +// Move markAllAsSeen to useFocusEffect, run once per focus +useFocusEffect( + useCallback(() => { + fetchMatches(); + + // Mark as seen after fetch completes + return () => { + // Optionally mark as seen on blur instead + }; + }, []) +); + +// Separate effect for marking seen - runs once after initial fetch +const [hasMarkedSeen, setHasMarkedSeen] = useState(false); + +useEffect(() => { + if (matches.length > 0 && !hasMarkedSeen) { + markAllAsSeen(); + setHasMarkedSeen(true); + } +}, [matches.length, hasMarkedSeen]); + +// Reset on screen blur +useFocusEffect( + useCallback(() => { + return () => setHasMarkedSeen(false); + }, []) +); +``` + +**Acceptance Criteria:** +- [ ] `markAllAsSeen()` only called once per screen visit +- [ ] New matches still get marked as seen +- [ ] No regression in notification badge behavior + +--- + +### Task 6: Batch Message Status Updates + +**Impact:** Reduces 2 queries to 1 when opening chat + +**Current Behavior:** +- `useMessageSubscription.ts:125-143` makes separate updates for: + 1. Messages to mark as read + 2. Messages to mark as delivered + +**Files to Modify:** +- `apps/mobile/src/hooks/useMessageSubscription.ts` + +**Implementation:** +```typescript +// Combine into single update operation +const markMessagesAsReadAndDelivered = async (messages: Message[]) => { + const now = new Date().toISOString(); + const userId = useAuthStore.getState().user?.id; + + // Find messages that need updating + const messagesToUpdate = messages.filter( + m => m.user_id !== userId && (!m.read_at || !m.delivered_at) + ); + + if (messagesToUpdate.length === 0) return; + + const messageIds = messagesToUpdate.map(m => m.id); + + // Single update query + await supabase + .from("messages") + .update({ + delivered_at: now, + read_at: now + }) + .in("id", messageIds) + .is("read_at", null); // Only update unread +}; +``` + +**Acceptance Criteria:** +- [ ] Single query updates both delivered_at and read_at +- [ ] Read receipts still work correctly +- [ ] No regression in message status display + +--- + +### Task 7: Debounce Answer Gap Check + +**Impact:** Reduces RPC calls during rapid swiping + +**Current Behavior:** +- `swipe.tsx:171` checks answer gap on mount +- `swipe.tsx:228` checks again after EVERY swipe response + +**Files to Modify:** +- `apps/mobile/app/(app)/swipe.tsx` + +**Implementation:** +```typescript +// Add debounced version +const debouncedCheckAnswerGap = useMemo( + () => debounce(checkAnswerGap, 2000), // 2 second debounce + [checkAnswerGap] +); + +// On response submit +const handleResponse = async (answer: Answer) => { + await submitResponse(questionId, answer); + + // Debounced check - won't fire if user swipes again within 2s + debouncedCheckAnswerGap(); +}; + +// Cleanup on unmount +useEffect(() => { + return () => debouncedCheckAnswerGap.cancel(); +}, []); +``` + +**Acceptance Criteria:** +- [ ] Answer gap only checked after swiping pauses +- [ ] Still checks on initial mount +- [ ] Gap modal still appears when threshold exceeded + +--- + +## Low Priority Tasks + +### Task 8: Add Stale Time to Packs Store + +**Impact:** Prevents unnecessary refetches of static data + +**Files to Modify:** +- `apps/mobile/src/store/packsStore.ts` + +**Implementation:** +```typescript +// Add to store state +packsLastFetched: number | null; + +// In fetchPacks +fetchPacks: async () => { + const STALE_TIME = 10 * 60 * 1000; // 10 minutes + const { packsLastFetched, packs } = get(); + + if (packs.length > 0 && + packsLastFetched && + Date.now() - packsLastFetched < STALE_TIME) { + return; // Use cached data + } + + // ... existing fetch logic + set({ packs: data, packsLastFetched: Date.now() }); +}; + +// Add force refresh for when user explicitly requests +fetchPacksForce: async () => { + set({ packsLastFetched: null }); + return get().fetchPacks(); +}; +``` + +--- + +### Task 9: Persist Matches to AsyncStorage + +**Impact:** Faster initial load, offline capability + +**Files to Modify:** +- `apps/mobile/src/store/matchStore.ts` + +**Implementation:** +```typescript +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { persist } from "zustand/middleware"; + +export const useMatchStore = create()( + persist( + (set, get) => ({ + // ... existing store + }), + { + name: "match-storage", + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + matches: state.matches, + // Don't persist loading states + }), + } + ) +); +``` + +--- + +### Task 10: Combine Couple + Partner Fetch + +**Impact:** Reduces 2 queries to 1 in fetchCouple + +**Current Behavior:** +- `authStore.ts:137-149` fetches couple, then partner separately + +**Files to Modify:** +- `apps/mobile/src/store/authStore.ts` + +**Implementation:** +```typescript +// Single query with join +const { data: coupleData } = await supabase + .from("couples") + .select(` + *, + profiles!inner(*) + `) + .eq("id", user.couple_id) + .single(); + +const partner = coupleData.profiles.find(p => p.id !== user.id); +set({ couple: coupleData, partner }); +``` + +--- + +## Implementation Order + +| Priority | Task | Effort | Impact | +|----------|------|--------|--------| +| 1 | Task 1: Cache partner key | Low | High | +| 2 | Task 2: Store-based notifications | Low | High | +| 3 | Task 3: Remove polling | Low | Medium | +| 4 | Task 5: Fix mark as seen | Low | Medium | +| 5 | Task 6: Batch message updates | Low | Low | +| 6 | Task 7: Debounce gap check | Low | Low | +| 7 | Task 4: N+1 fix | Medium | Medium | +| 8 | Task 8: Packs stale time | Low | Low | +| 9 | Task 10: Combine couple fetch | Low | Low | +| 10 | Task 9: Persist matches | Medium | Low | + +--- + +## Testing Checklist + +After implementing optimizations: + +- [ ] E2EE messaging still works (send/receive encrypted messages) +- [ ] Partner joining couple is detected in real-time +- [ ] Match notifications display correctly +- [ ] New matches appear and are marked as seen +- [ ] Message read receipts work +- [ ] Answer gap modal appears when appropriate +- [ ] Packs load and display correctly +- [ ] No increase in error rates (monitor logs) + +--- + +## Metrics to Track + +Before/after comparison: + +1. **API calls per session** - Target: 40% reduction +2. **Time to interactive on home screen** - Target: <500ms +3. **Message send latency** - Target: No increase +4. **Battery usage** - Target: 20% reduction in background activity + +--- + +## Notes + +- All caching should have invalidation strategies +- Consider adding React Query or TanStack Query for more sophisticated caching +- Real-time subscriptions are preferred over polling +- Database views can simplify complex queries but add maintenance overhead diff --git a/eas.json b/eas.json deleted file mode 100644 index f4001c5..0000000 --- a/eas.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "cli": { - "version": ">= 16.28.0", - "appVersionSource": "remote" - }, - "build": { - "development": { - "developmentClient": true, - "distribution": "internal" - }, - "preview": { - "distribution": "internal" - }, - "production": { - "autoIncrement": true - } - }, - "submit": { - "production": {} - } -}