mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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
This commit is contained in:
1 parent
198cd80dfd
commit
348ef221db
13 files changed
+777
-106
No files matched your search
@@ -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<MatchStore>()(
|
||||
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
|
||||
Reference in new issue
Block a user