Skip to content

Commit 9eff343

Browse files
committed
perf(mobile): optimize offline folder counts with caching and indexing
Performance Optimizations: - Consolidate SQL queries from 5 separate queries to 1 optimized query (~80% reduction) - Add composite database indexes for count queries (O(log n) instead of O(n) lookups) - Implement 10s short-lived cache for offline counts to prevent recalculation - Add helper functions to eliminate code duplication (112 lines deduplicated) Bug Fixes: - Fix folder counts not displaying when offline (calculate from SQLite cache) - Fix race condition in ViewNote attachment loading with proper cleanup - Fix infinite error toasts when viewing notes offline - Improve error handling to return complete interfaces UX Improvements: - Add 500ms debouncing for network status changes (prevents flaky connection issues) - Add prefetch during master password authentication for instant offline access - Add two-stage loading UI: "Securing Your Data" → "Caching Your Data" - Add loading spinner to prevent white screen flash Security: - Change default to encrypted cache for better security (decrypt on-demand) - Add "Offline Cache Security" documentation in Settings - Maintain end-to-end encryption throughout
1 parent 025ac5b commit 9eff343

12 files changed

Lines changed: 431 additions & 46 deletions

File tree

apps/mobile/v1/src/components/AppWrapper.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export const AppWrapper: React.FC<AppWrapperProps> = ({ children }) => {
2222
isNewSetup,
2323
isChecking,
2424
userId,
25+
loadingStage,
26+
cacheMode,
2527
onPasswordSuccess,
2628
} = useMasterPassword();
2729

@@ -124,6 +126,8 @@ export const AppWrapper: React.FC<AppWrapperProps> = ({ children }) => {
124126
key={userId} // Force remount when userId changes to reset all state
125127
userId={userId || ''}
126128
isNewSetup={isNewSetup}
129+
loadingStage={loadingStage}
130+
cacheMode={cacheMode}
127131
onSuccess={onPasswordSuccess}
128132
/>
129133
);

apps/mobile/v1/src/components/MasterPasswordDialog/LoadingView.tsx

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import { styles } from './styles';
66

77
interface LoadingViewProps {
88
isNewSetup: boolean;
9+
stage?: 'securing' | 'caching';
10+
cacheMode?: 'encrypted' | 'decrypted';
911
}
1012

1113
/**
1214
* Loading view component
13-
* Shows during PBKDF2 key derivation with progress indicator
15+
* Shows during PBKDF2 key derivation and note caching
1416
*/
15-
export function LoadingView({ isNewSetup }: LoadingViewProps) {
17+
export function LoadingView({ isNewSetup, stage = 'securing', cacheMode = 'encrypted' }: LoadingViewProps) {
1618
const theme = useTheme();
1719
const [dots, setDots] = useState('');
1820

@@ -28,6 +30,50 @@ export function LoadingView({ isNewSetup }: LoadingViewProps) {
2830
return () => clearInterval(interval);
2931
}, []);
3032

33+
// Content for "Securing Your Data" stage
34+
if (stage === 'securing') {
35+
return (
36+
<View style={styles.loadingContent}>
37+
<View style={styles.loadingCenter}>
38+
<ActivityIndicator
39+
size="large"
40+
color={theme.colors.primary}
41+
style={{ marginBottom: 24 }}
42+
/>
43+
44+
<Text style={[styles.loadingTitle, { color: theme.colors.foreground }]}>
45+
Securing Your Data{dots}
46+
</Text>
47+
48+
<View
49+
style={[
50+
styles.notice,
51+
{
52+
backgroundColor: theme.colors.card,
53+
borderColor: theme.colors.border,
54+
},
55+
]}
56+
>
57+
<Text style={[styles.noticeText, { color: theme.colors.foreground }]}>
58+
{isNewSetup
59+
? 'We are generating military-grade encryption with 250,000 security iterations to protect your notes. Please wait and do not close the app.'
60+
: 'Verifying your master password and loading encryption keys. This may take a moment.'}
61+
</Text>
62+
<Text
63+
style={[
64+
styles.noticeText,
65+
{ color: theme.colors.foreground, marginTop: 12, fontWeight: '600' },
66+
]}
67+
>
68+
This process can take 2-5 minutes. The app may appear frozen but it&apos;s working{dots}
69+
</Text>
70+
</View>
71+
</View>
72+
</View>
73+
);
74+
}
75+
76+
// Content for "Caching Your Data" stage
3177
return (
3278
<View style={styles.loadingContent}>
3379
<View style={styles.loadingCenter}>
@@ -38,7 +84,7 @@ export function LoadingView({ isNewSetup }: LoadingViewProps) {
3884
/>
3985

4086
<Text style={[styles.loadingTitle, { color: theme.colors.foreground }]}>
41-
Securing Your Data{dots}
87+
Caching Your Data{dots}
4288
</Text>
4389

4490
<View
@@ -51,17 +97,19 @@ export function LoadingView({ isNewSetup }: LoadingViewProps) {
5197
]}
5298
>
5399
<Text style={[styles.noticeText, { color: theme.colors.foreground }]}>
54-
{isNewSetup
55-
? 'We are generating military-grade encryption with 250,000 security iterations to protect your notes. Please wait and do not close the app.'
56-
: 'Verifying your master password and loading encryption keys. This may take a moment.'}
100+
{cacheMode === 'decrypted'
101+
? 'Downloading and decrypting all your notes for instant offline access. This improves performance but stores decrypted content locally.'
102+
: 'Downloading all your notes in encrypted form for offline access. Notes will be decrypted on-demand for better security.'}
57103
</Text>
58104
<Text
59105
style={[
60106
styles.noticeText,
61107
{ color: theme.colors.foreground, marginTop: 12, fontWeight: '600' },
62108
]}
63109
>
64-
This process can take 2-5 minutes. The app may appear frozen but it&apos;s working{dots}
110+
{cacheMode === 'decrypted'
111+
? `This may take 5-10 seconds${dots}`
112+
: `This should only take a few seconds${dots}`}
65113
</Text>
66114
</View>
67115
</View>

apps/mobile/v1/src/components/MasterPasswordDialog/index.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { useKeyboardHandler } from './useKeyboardHandler';
1212
interface MasterPasswordScreenProps {
1313
userId: string;
1414
isNewSetup: boolean;
15+
loadingStage?: 'securing' | 'caching';
16+
cacheMode?: 'encrypted' | 'decrypted';
1517
onSuccess: (password: string) => Promise<void>;
1618
}
1719

@@ -21,6 +23,8 @@ interface MasterPasswordScreenProps {
2123
*/
2224
export function MasterPasswordScreen({
2325
isNewSetup,
26+
loadingStage = 'securing',
27+
cacheMode = 'encrypted',
2428
onSuccess,
2529
}: MasterPasswordScreenProps) {
2630
const theme = useTheme();
@@ -72,7 +76,11 @@ export function MasterPasswordScreen({
7276
onSubmit={handleFormSubmit}
7377
/>
7478
) : (
75-
<LoadingView isNewSetup={isNewSetup} />
79+
<LoadingView
80+
isNewSetup={isNewSetup}
81+
stage={loadingStage}
82+
cacheMode={cacheMode}
83+
/>
7684
)}
7785
</ScrollView>
7886
</Animated.View>

apps/mobile/v1/src/hooks/useMasterPassword.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
unlockWithMasterPassword,
1111
} from '../lib/encryption';
1212
import { logger } from '../lib/logger';
13+
import { getCacheDecryptedContentPreference } from '../lib/preferences';
14+
import { useApiService } from '../services/api';
1315

1416
// Global function to force all hook instances to refresh
1517
export function forceGlobalMasterPasswordRefresh() {
@@ -23,6 +25,9 @@ export function useMasterPassword() {
2325
const [isNewSetup, setIsNewSetup] = useState(false);
2426
const [isChecking, setIsChecking] = useState(true);
2527
const [, setLastCheckTime] = useState<number>(0);
28+
const [loadingStage, setLoadingStage] = useState<'securing' | 'caching'>('securing');
29+
const [cacheMode, setCacheMode] = useState<'encrypted' | 'decrypted'>('encrypted');
30+
const api = useApiService();
2631

2732
const userId = user?.id;
2833

@@ -111,6 +116,9 @@ export function useMasterPassword() {
111116
}
112117

113118
try {
119+
// Stage 1: Securing (PBKDF2 key derivation)
120+
setLoadingStage('securing');
121+
114122
if (isNewSetup) {
115123
// Setting up new master password
116124
await setupMasterPassword(password, userId);
@@ -133,7 +141,60 @@ export function useMasterPassword() {
133141
});
134142
}
135143

136-
// Successfully authenticated - update state
144+
// Stage 2: Caching (prefetch notes and folders)
145+
setLoadingStage('caching');
146+
147+
// Check cache mode preference
148+
const cacheDecrypted = await getCacheDecryptedContentPreference();
149+
setCacheMode(cacheDecrypted ? 'decrypted' : 'encrypted');
150+
151+
if (__DEV__) {
152+
console.log(`[PREFETCH] Starting prefetch with ${cacheDecrypted ? 'DECRYPTED' : 'ENCRYPTED'} cache mode`);
153+
}
154+
155+
try {
156+
const prefetchStart = performance.now();
157+
158+
// Prefetch folders first (faster)
159+
if (__DEV__) console.log('[PREFETCH] Step 1: Fetching folders...');
160+
await api.getFolders();
161+
if (__DEV__) console.log('[PREFETCH] Step 1 complete: Folders fetched');
162+
163+
// Then prefetch notes (slower)
164+
if (__DEV__) console.log('[PREFETCH] Step 2: Fetching notes...');
165+
166+
// Add 15 second timeout for notes fetch
167+
// IMPORTANT: Skip background refresh during prefetch to prevent hanging
168+
const notesFetchPromise = api.getNotes({}, { skipBackgroundRefresh: true });
169+
const timeoutPromise = new Promise<never>((_, reject) => {
170+
setTimeout(() => reject(new Error('Notes fetch timeout after 15 seconds')), 15000);
171+
});
172+
173+
await Promise.race([notesFetchPromise, timeoutPromise]);
174+
if (__DEV__) console.log('[PREFETCH] Step 2 complete: Notes fetched');
175+
176+
const prefetchEnd = performance.now();
177+
if (__DEV__) {
178+
console.log(`[PREFETCH] ✅ Completed in ${(prefetchEnd - prefetchStart).toFixed(2)}ms - cache ready!`);
179+
}
180+
181+
logger.recordEvent('notes_prefetch_completed', {
182+
durationMs: prefetchEnd - prefetchStart,
183+
cacheMode: cacheDecrypted ? 'decrypted' : 'encrypted',
184+
});
185+
} catch (error) {
186+
// Log error but continue - user can still use app, they'll just fetch on demand
187+
if (__DEV__) {
188+
console.error('[PREFETCH] Failed to prefetch notes (non-critical):', error);
189+
}
190+
logger.warn('[PREFETCH] Failed to prefetch notes', {
191+
attributes: {
192+
error: error instanceof Error ? error.message : 'Unknown error',
193+
},
194+
});
195+
}
196+
197+
// Successfully authenticated and cached - update state
137198
setNeedsUnlock(false);
138199
setIsNewSetup(false);
139200
setIsChecking(false);
@@ -162,6 +223,8 @@ export function useMasterPassword() {
162223
isNewSetup,
163224
isChecking,
164225
userId,
226+
loadingStage,
227+
cacheMode,
165228
onPasswordSuccess,
166229
signOut,
167230
refreshStatus: checkMasterPasswordStatus,

apps/mobile/v1/src/lib/database/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ async function migrateDatabase(db: SQLite.SQLiteDatabase): Promise<void> {
8787
CREATE INDEX IF NOT EXISTS idx_notes_archived ON notes(archived);
8888
CREATE INDEX IF NOT EXISTS idx_notes_deleted ON notes(deleted);
8989
CREATE INDEX IF NOT EXISTS idx_folders_user_id ON folders(user_id);
90+
91+
-- Composite indexes for optimized count queries
92+
CREATE INDEX IF NOT EXISTS idx_notes_status_composite ON notes(deleted, archived, starred);
93+
CREATE INDEX IF NOT EXISTS idx_notes_folder_status ON notes(folder_id, deleted, archived) WHERE folder_id IS NOT NULL;
9094
`);
9195

9296
console.log('[SQLite] Migration to v3 completed');
@@ -238,6 +242,10 @@ export async function initializeDatabase(): Promise<SQLite.SQLiteDatabase> {
238242
CREATE INDEX IF NOT EXISTS idx_folders_user_id ON folders(user_id);
239243
CREATE INDEX IF NOT EXISTS idx_cache_resource ON cache_metadata(resource_type, resource_id);
240244
CREATE INDEX IF NOT EXISTS idx_sync_status ON sync_queue(status);
245+
246+
-- Composite indexes for optimized count queries
247+
CREATE INDEX IF NOT EXISTS idx_notes_status_composite ON notes(deleted, archived, starred);
248+
CREATE INDEX IF NOT EXISTS idx_notes_folder_status ON notes(folder_id, deleted, archived) WHERE folder_id IS NOT NULL;
241249
`);
242250

243251
console.log('[SQLite] Database initialized successfully');

apps/mobile/v1/src/lib/preferences.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,27 @@ const PREFERENCE_KEYS = {
1717
/**
1818
* Get whether to cache decrypted content locally
1919
*
20-
* When enabled (default):
20+
* When enabled:
2121
* - Decrypted notes stored in SQLite for instant loading (~50ms)
2222
* - Better performance but decrypted data stored locally
2323
*
24-
* When disabled:
24+
* When disabled (default):
2525
* - Only encrypted notes stored, decrypt on each load (~550ms)
2626
* - Better security but slower loading
2727
*/
2828
export async function getCacheDecryptedContentPreference(): Promise<boolean> {
2929
try {
3030
const value = await AsyncStorage.getItem(PREFERENCE_KEYS.CACHE_DECRYPTED_CONTENT);
3131

32-
// Default to true (better UX) if not set
32+
// Default to false (better security) if not set
3333
if (value === null) {
34-
return true;
34+
return false;
3535
}
3636

3737
return value === 'true';
3838
} catch (error) {
3939
console.error('[Preferences] Failed to get cache preference:', error);
40-
return true; // Default to enabled on error
40+
return false; // Default to disabled on error
4141
}
4242
}
4343

apps/mobile/v1/src/screens/FoldersScreen.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
1010

1111
import { OfflineIndicator } from '../components/OfflineIndicator';
1212
import { ACTION_BUTTON, FOLDER_CARD, FOLDER_COLORS } from '../constants/ui';
13+
import { useNetworkStatus } from '../hooks/useNetworkStatus';
1314
import { type Folder, type FolderCounts,useApiService } from '../services/api';
1415
import { useTheme } from '../theme';
1516

@@ -49,6 +50,7 @@ export default function FoldersScreen() {
4950
const theme = useTheme();
5051
const api = useApiService();
5152
const router = useRouter();
53+
const { isConnected, isInternetReachable } = useNetworkStatus();
5254
const [allFolders, setAllFolders] = useState<Folder[]>([]);
5355
const [loading, setLoading] = useState(true);
5456
const [showLoading, setShowLoading] = useState(false);
@@ -199,6 +201,21 @@ export default function FoldersScreen() {
199201
};
200202
}, [isFocused, loadFoldersData]);
201203

204+
// Reload data when network status changes (debounced to prevent flaky connections)
205+
useEffect(() => {
206+
if (!isFocused) return;
207+
208+
// Debounce network status changes to avoid rapid reloads on flaky connections
209+
const timer = setTimeout(() => {
210+
if (__DEV__) {
211+
console.log('[FoldersScreen] Network status changed, reloading data');
212+
}
213+
loadFoldersData();
214+
}, 500); // 500ms debounce
215+
216+
return () => clearTimeout(timer);
217+
}, [isConnected, isInternetReachable, isFocused, loadFoldersData]);
218+
202219
// Handle loading delay
203220
useEffect(() => {
204221
let timer: ReturnType<typeof setTimeout>;

apps/mobile/v1/src/screens/ListNotes/index.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { BottomSheetModal } from '@gorhom/bottom-sheet';
33
import AsyncStorage from '@react-native-async-storage/async-storage';
44
import { useFocusEffect } from '@react-navigation/native';
55
import React, { useCallback,useEffect, useMemo, useRef, useState } from 'react';
6-
import { Alert, Animated, FlatList, RefreshControl, StyleSheet, View } from 'react-native';
6+
import { ActivityIndicator, Alert, Animated, FlatList, RefreshControl, StyleSheet, View } from 'react-native';
77

88
import { type Folder, type Note, useApiService } from '../../services/api';
99
import { useTheme } from '../../theme';
@@ -258,6 +258,12 @@ export default function NotesListScreen({ navigation, route, renderHeader, scrol
258258

259259
return (
260260
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
261+
{/* Show loading spinner on initial load */}
262+
{loading && notes.length === 0 && (
263+
<View style={styles.loadingContainer}>
264+
<ActivityIndicator size="large" color={theme.colors.primary} />
265+
</View>
266+
)}
261267

262268
<Animated.FlatList
263269
ref={flatListRef}
@@ -315,6 +321,16 @@ const styles = StyleSheet.create({
315321
container: {
316322
flex: 1,
317323
},
324+
loadingContainer: {
325+
position: 'absolute',
326+
top: 0,
327+
left: 0,
328+
right: 0,
329+
bottom: 0,
330+
justifyContent: 'center',
331+
alignItems: 'center',
332+
zIndex: 10,
333+
},
318334
scrollView: {
319335
flex: 1,
320336
paddingHorizontal: 0,

0 commit comments

Comments
 (0)