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
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"expo": {
|
|
||||||
"extra": {
|
|
||||||
"eas": {
|
|
||||||
"projectId": "807ca0af-e9bd-40e8-a658-f3382072c724"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"owner": "jjbrunton-apps"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -93,7 +93,7 @@ android {
|
|||||||
applicationId 'com.sauci.app'
|
applicationId 'com.sauci.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 9
|
||||||
versionName "1.0.0"
|
versionName "1.0.0"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ buildscript {
|
|||||||
buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
|
buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
|
||||||
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24')
|
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24')
|
||||||
compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35')
|
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'
|
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
|
||||||
|
|
||||||
ndkVersion = "26.1.10909125"
|
ndkVersion = "26.1.10909125"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.sauci.app",
|
"bundleIdentifier": "com.sauci.app",
|
||||||
|
"buildNumber": "5",
|
||||||
"usesAppleSignIn": true,
|
"usesAppleSignIn": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"package": "com.sauci.app",
|
"package": "com.sauci.app",
|
||||||
|
"versionCode": 10,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
@@ -45,6 +47,17 @@
|
|||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-build-properties",
|
||||||
|
{
|
||||||
|
"android": {
|
||||||
|
"blockedPermissions": [
|
||||||
|
"android.permission.READ_MEDIA_IMAGES",
|
||||||
|
"android.permission.READ_MEDIA_VIDEO"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-apple-authentication",
|
"expo-apple-authentication",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
getAdminPublicKey,
|
getAdminPublicKey,
|
||||||
getAdminKeyId,
|
getAdminKeyId,
|
||||||
MESSAGE_VERSION_E2EE,
|
MESSAGE_VERSION_E2EE,
|
||||||
|
isValidPublicKeyJwk,
|
||||||
|
sanitizePublicKeyJwk,
|
||||||
} from '../../../lib/encryption';
|
} from '../../../lib/encryption';
|
||||||
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
|
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
|
||||||
import { useEncryptionKeys } from '../../../hooks';
|
import { useEncryptionKeys } from '../../../hooks';
|
||||||
@@ -80,7 +82,18 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
|||||||
|
|
||||||
const adminPublicKey = await getAdminPublicKey();
|
const adminPublicKey = await getAdminPublicKey();
|
||||||
const adminKeyId = await getAdminKeyId();
|
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);
|
console.log('[E2EE Media Upload] Partner public key available:', !!partnerPublicKey);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import * as FileSystem from 'expo-file-system';
|
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 type { KeysMetadata, RSAPrivateKeyJWK } from '../lib/encryption';
|
||||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||||
import { getCachedSignedUrl, getStoragePath } from '../lib/imageCache';
|
import { getCachedSignedUrl, getStoragePath } from '../lib/imageCache';
|
||||||
@@ -278,7 +278,83 @@ export function useDecryptedMedia({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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) {
|
if (isMounted.current) {
|
||||||
setState({
|
setState({
|
||||||
uri: null,
|
uri: null,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
encryptTextMessage,
|
encryptTextMessage,
|
||||||
getAdminPublicKey,
|
getAdminPublicKey,
|
||||||
getAdminKeyId,
|
getAdminKeyId,
|
||||||
|
isValidPublicKeyJwk,
|
||||||
|
sanitizePublicKeyJwk,
|
||||||
} from '../lib/encryption';
|
} from '../lib/encryption';
|
||||||
import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption';
|
import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption';
|
||||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||||
@@ -87,11 +89,22 @@ export function useEncryptedSend(): UseEncryptedSendReturn {
|
|||||||
const adminKeyId = await getAdminKeyId();
|
const adminKeyId = await getAdminKeyId();
|
||||||
|
|
||||||
// Get partner's public key from the freshly fetched profile
|
// 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
|
| RSAPublicKeyJWK
|
||||||
| null
|
| null
|
||||||
| undefined;
|
| 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);
|
console.log('[E2EE Send] Partner public key available:', !!partnerPublicKey);
|
||||||
|
|
||||||
// Encrypt the message using the key from global store
|
// Encrypt the message using the key from global store
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import { Alert, Platform } from 'react-native';
|
import { Alert, Platform } from 'react-native';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
|
||||||
@@ -40,6 +40,10 @@ const DEFAULT_CONFIG: Required<MediaPickerConfig> = {
|
|||||||
*/
|
*/
|
||||||
export const useMediaPicker = (config?: MediaPickerConfig) => {
|
export const useMediaPicker = (config?: MediaPickerConfig) => {
|
||||||
const settings = { ...DEFAULT_CONFIG, ...config };
|
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.
|
* Request camera permissions with user-friendly error handling.
|
||||||
@@ -62,26 +66,37 @@ export const useMediaPicker = (config?: MediaPickerConfig) => {
|
|||||||
* @returns MediaResult if selected, null if cancelled
|
* @returns MediaResult if selected, null if cancelled
|
||||||
*/
|
*/
|
||||||
const pickMedia = useCallback(async (): Promise<MediaResult | null> => {
|
const pickMedia = useCallback(async (): Promise<MediaResult | null> => {
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({
|
if (isPresentingRef.current) return null;
|
||||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
isPresentingRef.current = true;
|
||||||
quality: settings.imageQuality,
|
|
||||||
videoMaxDuration: settings.libraryVideoMaxDuration,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.canceled) {
|
try {
|
||||||
return null;
|
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]);
|
}, [settings.imageQuality, settings.libraryVideoMaxDuration]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,26 +104,43 @@ export const useMediaPicker = (config?: MediaPickerConfig) => {
|
|||||||
* @returns MediaResult if captured, null if cancelled or permission denied
|
* @returns MediaResult if captured, null if cancelled or permission denied
|
||||||
*/
|
*/
|
||||||
const takePhoto = useCallback(async (): Promise<MediaResult | null> => {
|
const takePhoto = useCallback(async (): Promise<MediaResult | null> => {
|
||||||
const hasPermission = await requestCameraPermission();
|
if (isPresentingRef.current) return null;
|
||||||
if (!hasPermission) {
|
isPresentingRef.current = true;
|
||||||
return null;
|
|
||||||
|
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]);
|
}, [settings.imageQuality, requestCameraPermission]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,29 +148,44 @@ export const useMediaPicker = (config?: MediaPickerConfig) => {
|
|||||||
* @returns MediaResult if recorded, null if cancelled or permission denied
|
* @returns MediaResult if recorded, null if cancelled or permission denied
|
||||||
*/
|
*/
|
||||||
const recordVideo = useCallback(async (): Promise<MediaResult | null> => {
|
const recordVideo = useCallback(async (): Promise<MediaResult | null> => {
|
||||||
const hasPermission = await requestCameraPermission();
|
if (isPresentingRef.current) return null;
|
||||||
if (!hasPermission) {
|
isPresentingRef.current = true;
|
||||||
return null;
|
|
||||||
|
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]);
|
}, [settings.imageQuality, settings.cameraVideoMaxDuration, requestCameraPermission]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ export async function decryptMediaFile(
|
|||||||
);
|
);
|
||||||
const encryptedData = base64ToArrayBuffer(encryptedBase64);
|
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
|
const wrappedKeyBase64 = isRecipient
|
||||||
? keysMetadata.recipient_wrapped_key
|
? keysMetadata.recipient_wrapped_key
|
||||||
: keysMetadata.sender_wrapped_key;
|
: keysMetadata.sender_wrapped_key;
|
||||||
@@ -61,11 +67,10 @@ export async function decryptMediaFile(
|
|||||||
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
|
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
|
||||||
const aesKey = await importAESKey(rawAesKey);
|
const aesKey = await importAESKey(rawAesKey);
|
||||||
|
|
||||||
// 4. Decrypt file
|
// 5. Decrypt file
|
||||||
const iv = base64ToUint8Array(encryptionIv);
|
|
||||||
const decryptedData = await decryptAES(encryptedData, aesKey, iv);
|
const decryptedData = await decryptAES(encryptedData, aesKey, iv);
|
||||||
|
|
||||||
// 5. Write decrypted file
|
// 6. Write decrypted file
|
||||||
const ext = mediaType === 'video' ? 'mp4' : 'jpg';
|
const ext = mediaType === 'video' ? 'mp4' : 'jpg';
|
||||||
const decryptedUri = `${FileSystem.cacheDirectory}${Date.now()}_decrypted.${ext}`;
|
const decryptedUri = `${FileSystem.cacheDirectory}${Date.now()}_decrypted.${ext}`;
|
||||||
|
|
||||||
|
|||||||
@@ -35,18 +35,23 @@ export async function decryptTextMessage(
|
|||||||
throw new Error('No wrapped key available for decryption');
|
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);
|
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
|
||||||
|
|
||||||
// 3. Import AES key
|
// 4. Import AES key
|
||||||
const aesKey = await importAESKey(rawAesKey);
|
const aesKey = await importAESKey(rawAesKey);
|
||||||
|
|
||||||
// 4. Decrypt content
|
// 5. Decrypt content
|
||||||
const iv = base64ToUint8Array(encryptionIv);
|
|
||||||
const ciphertext = base64ToArrayBuffer(encryptedContent);
|
const ciphertext = base64ToArrayBuffer(encryptedContent);
|
||||||
|
|
||||||
const plaintext = await decryptAES(ciphertext, aesKey, iv);
|
const plaintext = await decryptAES(ciphertext, aesKey, iv);
|
||||||
|
|
||||||
// 5. Decode to string
|
// 6. Decode to string
|
||||||
return new TextDecoder().decode(plaintext);
|
return new TextDecoder().decode(plaintext);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
|||||||
import { supabase } from "../lib/supabase";
|
import { supabase } from "../lib/supabase";
|
||||||
import { Events } from "../lib/analytics";
|
import { Events } from "../lib/analytics";
|
||||||
import { clearKeys } from "../lib/encryption";
|
import { clearKeys } from "../lib/encryption";
|
||||||
|
import { clearDecryptedMediaCache } from "../hooks/useDecryptedMedia";
|
||||||
import type { Profile, Couple } from "@/types";
|
import type { Profile, Couple } from "@/types";
|
||||||
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from "../lib/encryption/types";
|
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from "../lib/encryption/types";
|
||||||
|
|
||||||
@@ -181,6 +182,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
console.error("Failed to clear encryption keys:", error);
|
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
|
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user