Skip to content

Commit eb9b344

Browse files
authored
Merge pull request #28 from typelets/feature/local-cache
What This PR Does This PR fixes critical offline functionality issues and significantly improves performance for the mobile app's offline-first architecture. Main Problems Fixed: 1. Folder counts were broken offline - When users went offline, all folder counts showed "0" instead of the actual number of notes. Now counts are calculated from the local SQLite cache. 2. App would crash/freeze when viewing notes offline - Opening a note while offline caused infinite error loops. Now the app properly checks network status before trying to load attachments. 3. Slow performance with repeated database queries - The app was running 5 separate SQL queries every time it needed to show counts. Now it runs just 1 optimized query, making it ~80% faster. 4. Attachments button disappeared - After fixing the crash, notes with attachments wouldn't show the attachments button. Added proper state tracking to fix this. Performance Improvements: - Reduced database queries from 5 to 1 using SQL optimization - Added database indexes so queries run in O(log n) instead of O(n) time - Added 10-second cache for offline counts so we don't recalculate on every screen render - Network status changes are now debounced by 500ms to prevent issues with flaky connections Better User Experience: - Login now prefetches your data so everything is instantly available offline - Two-stage loading screen shows "Securing Your Data" then "Caching Your Data" so users understand what's happening - Encrypted cache is now the default (more secure) - notes are decrypted only when you view them
2 parents f0d8e0b + 7df7acd commit eb9b344

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+4992
-1664
lines changed

.expo/devices.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"devices": []
3+
}

apps/mobile/v1/app.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"expo": {
33
"name": "Typelets",
44
"slug": "typelets",
5-
"version": "1.30.12",
5+
"version": "1.30.13",
66
"orientation": "default",
77
"icon": "./assets/images/icon.png",
88
"scheme": "typelets",
@@ -15,7 +15,7 @@
1515
"ios": {
1616
"icon": "./assets/images/ios-icon-dark.png",
1717
"bundleIdentifier": "com.typelets.mobile.ios",
18-
"buildNumber": "59",
18+
"buildNumber": "60",
1919
"supportsTablet": true,
2020
"infoPlist": {
2121
"NSCameraUsageDescription": "This app uses the camera to capture photos for your notes.",
@@ -26,7 +26,7 @@
2626
},
2727
"android": {
2828
"package": "com.typelets.notes",
29-
"versionCode": 59,
29+
"versionCode": 60,
3030
"softwareKeyboardLayoutMode": "resize",
3131
"adaptiveIcon": {
3232
"backgroundColor": "#FFFFFF",
@@ -63,7 +63,8 @@
6363
"backgroundColor": "#25262b"
6464
}
6565
}
66-
]
66+
],
67+
"expo-sqlite"
6768
],
6869
"experiments": {
6970
"typedRoutes": true

apps/mobile/v1/app/_layout.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import { DarkTheme, DefaultTheme, ThemeProvider as NavigationThemeProvider } fro
77
import * as Sentry from '@sentry/react-native';
88
import { Stack } from 'expo-router';
99
import { StatusBar } from 'expo-status-bar';
10+
import { useEffect } from 'react';
1011
import { View } from 'react-native';
1112
import { GestureHandlerRootView } from 'react-native-gesture-handler';
1213

1314
import { AppWrapper } from '@/src/components/AppWrapper';
1415
import ErrorBoundary from '@/src/components/ErrorBoundary';
16+
import { initializeDatabase } from '@/src/lib/database';
1517
import { ThemeProvider, useTheme } from '@/src/theme';
1618

1719

@@ -107,6 +109,18 @@ export default Sentry.wrap(function RootLayout() {
107109
console.log('Clerk key loaded:', clerkPublishableKey ? 'YES' : 'NO');
108110
}
109111

112+
// Initialize SQLite database on app start
113+
// Works in Expo Go and custom builds!
114+
useEffect(() => {
115+
initializeDatabase()
116+
.then(() => {
117+
console.log('[App] ✅ SQLite database initialized - offline caching enabled');
118+
})
119+
.catch((error) => {
120+
console.error('[App] ❌ Failed to initialize SQLite database:', error);
121+
});
122+
}, []);
123+
110124
// If no Clerk key, show error
111125
if (!clerkPublishableKey) {
112126
if (__DEV__) {

apps/mobile/v1/app/folder-notes.tsx

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
import { useLocalSearchParams, useRouter, Stack } from 'expo-router';
21
import { Ionicons } from '@expo/vector-icons';
3-
import { TouchableOpacity, View, StyleSheet, Text, Animated, Alert, Keyboard, Pressable } from 'react-native';
2+
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView, BottomSheetTextInput,BottomSheetView } from '@gorhom/bottom-sheet';
43
import * as Haptics from 'expo-haptics';
4+
import { Stack,useLocalSearchParams, useRouter } from 'expo-router';
5+
import { useCallback,useEffect, useMemo, useRef, useState } from 'react';
6+
import { Alert, Animated, Keyboard, Pressable,StyleSheet, Text, TouchableOpacity, View } from 'react-native';
57
import { SafeAreaView } from 'react-native-safe-area-context';
6-
import { useTheme } from '@/src/theme';
7-
import NotesListScreen from '@/src/screens/NotesListScreen';
8-
import { useApiService, type Folder } from '@/src/services/api';
9-
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
8+
9+
import { OfflineIndicator } from '@/src/components/OfflineIndicator';
1010
import { Input } from '@/src/components/ui/Input';
11-
import { BottomSheetModal, BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView, BottomSheetTextInput } from '@gorhom/bottom-sheet';
1211
import { FOLDER_COLORS } from '@/src/constants/ui';
12+
import NotesListScreen from '@/src/screens/ListNotes';
13+
import { type Folder,useApiService } from '@/src/services/api';
14+
import { useTheme } from '@/src/theme';
1315

1416
function getViewTitle(viewType: string): string {
1517
switch (viewType) {
@@ -81,12 +83,29 @@ export default function FolderNotesScreen() {
8183
// Build breadcrumbs by traversing up the folder hierarchy
8284
useEffect(() => {
8385
const buildBreadcrumbs = async () => {
84-
if (!params.folderId) {
86+
if (!params.folderId && !params.viewType) {
87+
setBreadcrumbs(['Notes']);
88+
// Still fetch folders for navigation menu
89+
try {
90+
const folders = await api.getFolders();
91+
setAllFolders(folders);
92+
} catch (error) {
93+
console.error('Failed to fetch folders:', error);
94+
setAllFolders([]);
95+
}
96+
return;
97+
}
98+
99+
if (params.viewType) {
85100
// For special views, just show the view name
86-
if (params.viewType) {
87-
setBreadcrumbs([getViewTitle(params.viewType as string)]);
88-
} else {
89-
setBreadcrumbs(['Notes']);
101+
setBreadcrumbs([getViewTitle(params.viewType as string)]);
102+
// Still fetch folders for navigation menu
103+
try {
104+
const folders = await api.getFolders();
105+
setAllFolders(folders);
106+
} catch (error) {
107+
console.error('Failed to fetch folders:', error);
108+
setAllFolders([]);
90109
}
91110
return;
92111
}
@@ -254,7 +273,7 @@ export default function FolderNotesScreen() {
254273
params: {
255274
folderId: params.folderId as string,
256275
folderName: params.folderName as string,
257-
viewType: params.viewType as string,
276+
viewType: params.viewType as 'all' | 'starred' | 'archived' | 'trash' | undefined,
258277
searchQuery: searchQuery, // Pass search query to NotesListScreen
259278
}
260279
};
@@ -398,6 +417,9 @@ export default function FolderNotesScreen() {
398417
}
399418
}
400419
}} activeTab="add" /> */}
420+
421+
{/* Offline Indicator - Floating Button */}
422+
<OfflineIndicator />
401423
</SafeAreaView>
402424
</Pressable>
403425

@@ -445,7 +467,11 @@ export default function FolderNotesScreen() {
445467
{/* Render folder tree hierarchically */}
446468
{(() => {
447469
const renderFolderTree = (parentId: string | null | undefined, depth: number = 0) => {
448-
const folders = allFolders.filter(f => f.parentId === parentId);
470+
// Handle both null and undefined for root folders
471+
const folders = allFolders.filter(f =>
472+
parentId ? f.parentId === parentId : !f.parentId
473+
);
474+
449475
return folders.map((folder) => {
450476
const isInBreadcrumb = breadcrumbFolders.some(bf => bf.id === folder.id);
451477
const isCurrent = breadcrumbFolders[breadcrumbFolders.length - 1]?.id === folder.id;

apps/mobile/v1/eas.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"developmentClient": true,
99
"distribution": "internal",
1010
"env": {
11-
"RCT_NEW_ARCH_ENABLED": "1"
11+
"RCT_NEW_ARCH_ENABLED": "0"
1212
}
1313
},
1414
"preview": {

apps/mobile/v1/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "v1",
33
"main": "index.js",
4-
"version": "1.30.12",
4+
"version": "1.30.13",
55
"scripts": {
66
"start": "expo start",
77
"reset-project": "node ./scripts/reset-project.js",
@@ -16,6 +16,7 @@
1616
"@expo/vector-icons": "^15.0.2",
1717
"@gorhom/bottom-sheet": "^5.2.6",
1818
"@react-native-async-storage/async-storage": "^2.2.0",
19+
"@react-native-community/netinfo": "11.4.1",
1920
"@react-navigation/elements": "^2.6.3",
2021
"@react-navigation/native": "^7.1.8",
2122
"@sentry/react-native": "~7.2.0",
@@ -33,6 +34,7 @@
3334
"expo-secure-store": "^15.0.7",
3435
"expo-sharing": "~14.0.7",
3536
"expo-splash-screen": "~31.0.10",
37+
"expo-sqlite": "~16.0.8",
3638
"expo-status-bar": "~3.0.8",
3739
"expo-symbols": "~1.0.7",
3840
"expo-system-ui": "~6.0.7",
@@ -53,6 +55,7 @@
5355
"eslint": "^9.25.0",
5456
"eslint-config-expo": "~10.0.0",
5557
"eslint-plugin-simple-import-sort": "^12.1.1",
58+
"expo-build-properties": "^1.0.9",
5659
"typescript": "~5.9.2"
5760
},
5861
"private": true

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ActivityIndicator,View } from 'react-native';
55
import { useMasterPassword } from '../hooks/useMasterPassword';
66
import { logger } from '../lib/logger';
77
import AuthScreen from '../screens/AuthScreen';
8+
import { useSyncOnReconnect } from '../services/sync/useSyncOnReconnect';
89
import { useTheme } from '../theme';
910
import { MasterPasswordScreen } from './MasterPasswordDialog';
1011

@@ -21,13 +22,18 @@ export const AppWrapper: React.FC<AppWrapperProps> = ({ children }) => {
2122
isNewSetup,
2223
isChecking,
2324
userId,
25+
loadingStage,
26+
cacheMode,
2427
onPasswordSuccess,
2528
} = useMasterPassword();
2629

2730
const [showLoading, setShowLoading] = useState(false);
2831
const lastUserIdRef = useRef<string | undefined>(undefined);
2932
const [userChanging, setUserChanging] = useState(false);
3033

34+
// Automatically sync pending mutations when device comes back online
35+
useSyncOnReconnect();
36+
3137
// Detect userId change SYNCHRONOUSLY in render
3238
if (userId !== lastUserIdRef.current) {
3339
lastUserIdRef.current = userId;
@@ -120,6 +126,8 @@ export const AppWrapper: React.FC<AppWrapperProps> = ({ children }) => {
120126
key={userId} // Force remount when userId changes to reset all state
121127
userId={userId || ''}
122128
isNewSetup={isNewSetup}
129+
loadingStage={loadingStage}
130+
cacheMode={cacheMode}
123131
onSuccess={onPasswordSuccess}
124132
/>
125133
);

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>

0 commit comments

Comments
 (0)