Skip to content

Commit c72bfef

Browse files
authored
Merge pull request #1174 from nissinanlung/fix/1096-1095-1090-distribution-cache-authtoken
fix: distribution config reconfiguration, LRU translation cache, secure token storage (#1096 #1095 #1090)
2 parents 38e06ac + 3ef901f commit c72bfef

3 files changed

Lines changed: 133 additions & 20 deletions

File tree

mobile/src/config/DistributionConfigManager.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ export class DistributionConfigManager {
2525
}
2626

2727
/**
28-
* Initialize singleton instance
28+
* Initialize singleton instance.
29+
* If called again with different platform/channel, reconfigures the existing
30+
* instance and warns. This prevents silently operating on stale config when
31+
* initialize() is called with changed arguments (Issue #1096).
2932
*/
3033
public static initialize(platform: Platform, channel?: ReleaseChannel): DistributionConfigManager {
3134
const releaseChannel = channel || getCurrentReleaseChannel();
@@ -34,6 +37,21 @@ export class DistributionConfigManager {
3437
platform,
3538
releaseChannel,
3639
);
40+
return DistributionConfigManager.instance;
41+
}
42+
43+
// Already initialized — check if the args changed
44+
const existing = DistributionConfigManager.instance;
45+
if (existing.platform !== platform || existing.channel !== releaseChannel) {
46+
console.warn(
47+
'DistributionConfigManager.initialize() called with different arguments ' +
48+
`(platform: ${existing.platform} -> ${platform}, ` +
49+
`channel: ${existing.channel} -> ${releaseChannel}). ` +
50+
'Reconfiguring the existing instance.',
51+
);
52+
existing.platform = platform;
53+
existing.channel = releaseChannel;
54+
existing.initializeConfig();
3755
}
3856
return DistributionConfigManager.instance;
3957
}

mobile/src/hooks/useChatTranslation.ts

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
/**
22
* useChatTranslation — Issue #617
33
* Hook for real-time per-message translation with locale switching.
4+
*
5+
* Issue #1095: Added LRU eviction to prevent unbounded cache growth.
6+
* The cache is capped at MAX_TRANSLATION_CACHE entries; when the cap is
7+
* reached, the oldest entries are evicted to maintain a bounded footprint.
48
*/
59

6-
import { useCallback, useState } from 'react';
10+
import { useCallback, useRef, useState } from 'react';
711
import { AppLocale } from '../i18n';
812
import { ChatTranslationService } from '../services/ChatTranslationService';
913

14+
/** Maximum number of cached message translations before LRU eviction. */
15+
const MAX_TRANSLATION_CACHE = 200;
16+
1017
export interface MessageTranslation {
1118
translatedText: string;
1219
isLoading: boolean;
@@ -16,6 +23,38 @@ export interface MessageTranslation {
1623

1724
export function useChatTranslation(targetLocale: AppLocale) {
1825
const [translations, setTranslations] = useState<Record<string, MessageTranslation>>({});
26+
const accessOrder = useRef<string[]>([]);
27+
28+
/**
29+
* Evict oldest entries when the cache exceeds MAX_TRANSLATION_CACHE.
30+
* Uses a simple FIFO strategy based on insertion order (accessOrder ref).
31+
*/
32+
const evictIfNeeded = useCallback((current: Record<string, MessageTranslation>) => {
33+
const keys = Object.keys(current);
34+
if (keys.length <= MAX_TRANSLATION_CACHE) return current;
35+
36+
// Evict oldest entries that are not currently visible
37+
const toRemove: string[] = [];
38+
const visibleKeys = new Set(
39+
accessOrder.current.filter((k) => current[k]?.isVisible),
40+
);
41+
42+
for (const key of accessOrder.current) {
43+
if (keys.length - toRemove.length <= MAX_TRANSLATION_CACHE) break;
44+
if (!visibleKeys.has(key)) {
45+
toRemove.push(key);
46+
}
47+
}
48+
49+
// Update access order
50+
accessOrder.current = accessOrder.current.filter((k) => !toRemove.includes(k));
51+
52+
const next = { ...current };
53+
for (const key of toRemove) {
54+
delete next[key];
55+
}
56+
return next;
57+
}, []);
1958

2059
const toggleTranslation = useCallback(
2160
async (messageId: string, originalText: string, sourceLocale: AppLocale = 'en') => {
@@ -32,26 +71,37 @@ export function useChatTranslation(targetLocale: AppLocale) {
3271

3372
// If already translated (cached), just show it
3473
if (current?.translatedText && !current.error) {
74+
// Move to end of access order (LRU update)
75+
accessOrder.current = accessOrder.current.filter((id) => id !== messageId);
76+
accessOrder.current.push(messageId);
3577
setTranslations((prev) => ({
3678
...prev,
3779
[messageId]: { ...prev[messageId], isVisible: true },
3880
}));
3981
return;
4082
}
4183

84+
// Track access order for LRU
85+
if (!accessOrder.current.includes(messageId)) {
86+
accessOrder.current.push(messageId);
87+
}
88+
4289
// Start loading
43-
setTranslations((prev) => ({
44-
...prev,
45-
[messageId]: { translatedText: '', isLoading: true, error: null, isVisible: true },
46-
}));
90+
setTranslations((prev) => {
91+
const updated = {
92+
...prev,
93+
[messageId]: { translatedText: '', isLoading: true, error: null, isVisible: true },
94+
};
95+
return evictIfNeeded(updated);
96+
});
4797

4898
try {
4999
const result = await ChatTranslationService.translate(
50100
originalText,
51101
targetLocale,
52102
sourceLocale,
53103
);
54-
setTranslations((prev) => ({
104+
setTranslations((prev) => evictIfNeeded({
55105
...prev,
56106
[messageId]: {
57107
translatedText: result.translatedText,
@@ -61,7 +111,7 @@ export function useChatTranslation(targetLocale: AppLocale) {
61111
},
62112
}));
63113
} catch {
64-
setTranslations((prev) => ({
114+
setTranslations((prev) => evictIfNeeded({
65115
...prev,
66116
[messageId]: {
67117
translatedText: '',
@@ -72,10 +122,11 @@ export function useChatTranslation(targetLocale: AppLocale) {
72122
}));
73123
}
74124
},
75-
[targetLocale, translations],
125+
[targetLocale, translations, evictIfNeeded],
76126
);
77127

78128
const clearTranslations = useCallback(() => {
129+
accessOrder.current = [];
79130
setTranslations({});
80131
}, []);
81132

mobile/src/store/authStore.ts

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,69 @@
11
/**
22
* Authentication store.
33
*
4-
* Persisted with `zustand/middleware`'s `persist` using AsyncStorage as the
5-
* storage adapter. `isHydrated` starts `false` and flips to `true` once the
6-
* persisted state has been rehydrated, so the app can gate rendering until the
7-
* auth state is known. Only the durable fields (user/token/isAuthenticated) are
8-
* persisted — `isHydrated` is always derived at runtime.
4+
* Issue #1090: The session token is now persisted via expo-secure-store
5+
* (Keychain/Keystore-backed) instead of plain AsyncStorage. AsyncStorage
6+
* is not encrypted at rest; expo-secure-store uses platform-native secure
7+
* storage. Non-sensitive fields (user) remain in AsyncStorage for fast access.
8+
*
9+
* `isHydrated` starts `false` and flips to `true` once the persisted state
10+
* has been rehydrated, so the app can gate rendering until the auth state
11+
* is known.
912
*/
1013
import AsyncStorage from '@react-native-async-storage/async-storage';
14+
import * as SecureStore from 'expo-secure-store';
1115
import { create } from 'zustand';
1216
import { createJSONStorage, persist } from 'zustand/middleware';
1317
import type { AuthState } from './types';
1418

15-
/** AsyncStorage key under which the auth store is persisted. */
19+
/** AsyncStorage key under which non-sensitive auth fields are persisted. */
1620
export const AUTH_STORAGE_KEY = '@stellar/auth';
21+
/** SecureStore key for the sensitive session token. */
22+
export const AUTH_TOKEN_KEY = 'stellar_auth_token';
23+
24+
/**
25+
* Custom storage adapter: splits sensitive token into SecureStore,
26+
* keeps non-sensitive fields in AsyncStorage.
27+
*/
28+
const secureHybridStorage = {
29+
getItem: async (name: string): Promise<string | null> => {
30+
const asyncData = await AsyncStorage.getItem(name);
31+
if (!asyncData) return null;
32+
try {
33+
const parsed = JSON.parse(asyncData);
34+
// Restore token from SecureStore if present
35+
if (parsed && parsed.isAuthenticated) {
36+
const token = await SecureStore.getItemAsync(AUTH_TOKEN_KEY);
37+
if (token) {
38+
parsed.token = token;
39+
}
40+
}
41+
return JSON.stringify(parsed);
42+
} catch {
43+
return asyncData;
44+
}
45+
},
46+
setItem: async (name: string, value: string): Promise<void> => {
47+
try {
48+
const parsed = JSON.parse(value);
49+
// Store token in SecureStore, strip it from AsyncStorage payload
50+
if (parsed && parsed.token) {
51+
await SecureStore.setItemAsync(AUTH_TOKEN_KEY, parsed.token);
52+
parsed.token = null;
53+
}
54+
await AsyncStorage.setItem(name, JSON.stringify(parsed));
55+
} catch {
56+
await AsyncStorage.setItem(name, value);
57+
}
58+
},
59+
removeItem: async (name: string): Promise<void> => {
60+
await SecureStore.deleteItemAsync(AUTH_TOKEN_KEY);
61+
await AsyncStorage.removeItem(name);
62+
},
63+
};
1764

1865
/**
1966
* Hook to access the authentication store.
20-
*
21-
* @example
22-
* const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
2367
*/
2468
export const useAuthStore = create<AuthState>()(
2569
persist(
@@ -34,8 +78,8 @@ export const useAuthStore = create<AuthState>()(
3478
}),
3579
{
3680
name: AUTH_STORAGE_KEY,
37-
storage: createJSONStorage(() => AsyncStorage),
38-
// Never persist the transient hydration flag.
81+
storage: createJSONStorage(() => secureHybridStorage),
82+
// Persist user, token, and isAuthenticated (token goes to SecureStore)
3983
partialize: (state) => ({
4084
user: state.user,
4185
token: state.token,

0 commit comments

Comments
 (0)