From 3dadd1e35c1b678af3c851c319666267ec7e24d0 Mon Sep 17 00:00:00 2001 From: Dishit Date: Tue, 9 Jun 2026 22:34:24 +0530 Subject: [PATCH 01/28] feat: add pro feature plug-in registry and license seam Package-approach scaffolding to gate pro features (MCP, etc.) without coupling the open core to pro code: - tool extension registry (services/tools/extensions.ts) - screen + settings-section registries - proLicenseService (isPro receipt gate) + bootstrap loadProFeatures that optionally activates @offgrid/pro (stub fallback when absent) - wire generationToolLoop, ChatInput, ChatMessageArea, AppNavigator, SettingsScreen to read the registries - metro resolver for @offgrid/pro with stub fallback - unit + integration tests for the registry and license seam Co-Authored-By: Dishit Karia --- App.tsx | 4 + .../generation/toolExtensionLoop.test.ts | 188 ++++++++++++++++++ .../settings/sectionRegistry.test.ts | 33 +++ .../unit/navigation/screenRegistry.test.ts | 38 ++++ .../unit/services/proLicenseService.test.ts | 70 +++++++ .../unit/services/tools/extensions.test.ts | 58 ++++++ android/settings.gradle | 5 +- metro.config.js | 31 ++- src/bootstrap/loadProFeatures.ts | 31 +++ src/bootstrap/proStub.js | 3 + src/components/ChatInput/Popovers.tsx | 19 ++ src/components/ChatInput/index.tsx | 6 + src/components/settings/sectionRegistry.ts | 15 ++ src/navigation/AppNavigator.tsx | 4 + src/navigation/screenRegistry.ts | 20 ++ src/screens/ChatScreen/ChatMessageArea.tsx | 18 +- .../ChatScreen/useChatGenerationActions.ts | 4 +- src/screens/SettingsScreen.tsx | 4 + src/services/generationToolLoop.ts | 56 ++++-- src/services/proLicenseService.ts | 35 ++++ src/services/tools/extensions.ts | 25 +++ 21 files changed, 640 insertions(+), 27 deletions(-) create mode 100644 __tests__/integration/generation/toolExtensionLoop.test.ts create mode 100644 __tests__/unit/components/settings/sectionRegistry.test.ts create mode 100644 __tests__/unit/navigation/screenRegistry.test.ts create mode 100644 __tests__/unit/services/proLicenseService.test.ts create mode 100644 __tests__/unit/services/tools/extensions.test.ts create mode 100644 src/bootstrap/loadProFeatures.ts create mode 100644 src/bootstrap/proStub.js create mode 100644 src/components/settings/sectionRegistry.ts create mode 100644 src/navigation/screenRegistry.ts create mode 100644 src/services/proLicenseService.ts create mode 100644 src/services/tools/extensions.ts diff --git a/App.tsx b/App.tsx index ef79264c..eb65294a 100644 --- a/App.tsx +++ b/App.tsx @@ -19,6 +19,7 @@ import { useDownloadListeners } from './src/hooks/useDownloads'; import { LockScreen } from './src/screens'; import { useAppState } from './src/hooks/useAppState'; import { useDownloadStore } from './src/stores/downloadStore'; +import { loadProFeatures } from './src/bootstrap/loadProFeatures'; LogBox.ignoreAllLogs(); // Suppress all logs @@ -166,6 +167,9 @@ function App() { // Initialize RAG database tables ragService.ensureReady().catch((err) => logger.error('Failed to initialize RAG service on startup', err)); + // Load pro features if @offgrid/pro is installed and receipt is valid + loadProFeatures().catch((err) => logger.error('[App] loadProFeatures failed:', err)); + // Show the UI immediately setIsInitializing(false); diff --git a/__tests__/integration/generation/toolExtensionLoop.test.ts b/__tests__/integration/generation/toolExtensionLoop.test.ts new file mode 100644 index 00000000..282d1eb8 --- /dev/null +++ b/__tests__/integration/generation/toolExtensionLoop.test.ts @@ -0,0 +1,188 @@ +/** + * Integration test: registered ToolExtension flows through runToolLoop. + * + * Verifies: + * 1. Extension system-prompt hint is appended to the system message. + * 2. Extension tool calls parsed from LLM text are collected. + * 3. Extension executor is called instead of the built-in executeToolCall. + * 4. Extension tool calls result in a tool-result message in the chat store. + * 5. Free path (no extensions): behaviour is identical to today. + */ + +import { runToolLoop, ToolLoopContext } from '../../../src/services/generationToolLoop'; +import { + registerToolExtension, + _clearExtensionsForTesting, + ToolExtension, +} from '../../../src/services/tools/extensions'; +import type { ToolCall, ToolResult } from '../../../src/services/tools/types'; +import { useChatStore } from '../../../src/stores'; +import { resetStores } from '../../utils/testHelpers'; + +// Mock the LLM so we control what it "says" +jest.mock('../../../src/services/llm'); +jest.mock('../../../src/services/litert'); +jest.mock('../../../src/services/activeModelService'); + +const { llmService } = require('../../../src/services/llm'); +const { liteRTService } = require('../../../src/services/litert'); + +// Mock executeToolCall so built-in tools don't actually run +jest.mock('../../../src/services/tools', () => ({ + getToolsAsOpenAISchema: jest.fn(() => []), + executeToolCall: jest.fn().mockResolvedValue({ name: 'builtin', content: 'builtin-result', durationMs: 1 }), +})); + +// Mock remote server store so we stay on the local path +jest.mock('../../../src/stores', () => { + const actual = jest.requireActual('../../../src/stores'); + return { + ...actual, + useRemoteServerStore: { + getState: () => ({ activeServerId: null, activeRemoteTextModelId: null }), + }, + }; +}); + +// Fake extension that parses tool_name tags +const MCP_TOOL_NAME = 'mcp_fake_tool'; +const MCP_HINT = '\n\nMCP tools available:\n- mcp_fake_tool: A fake MCP tool'; +const MCP_RESULT = 'mcp-result-content'; + +function makeFakeExtension(executorMock: jest.Mock): ToolExtension { + return { + id: 'mcp', + getSystemPromptHint: () => MCP_HINT, + parseToolCalls: (text: string): ToolCall[] => { + const match = /([\s\S]*?)<\/mcp_call>/.exec(text); + if (!match) return []; + return [{ id: 'mcp-tc-1', name: match[1].trim(), arguments: {} }]; + }, + stripFromVisibleText: (text: string) => text.replace(/[\s\S]*?<\/mcp_call>/g, '').trim(), + canHandle: (name: string) => name === MCP_TOOL_NAME, + execute: executorMock, + enabledToolCount: () => 1, + }; +} + +function makeCtx(overrides: Partial = {}): ToolLoopContext { + const conversationId = 'test-conv-1'; + useChatStore.getState().createConversation(conversationId); + return { + conversationId, + messages: [ + { id: 'sys', role: 'system', content: 'You are helpful.', timestamp: 0 }, + { id: 'u1', role: 'user', content: 'Run the MCP tool.', timestamp: 1 }, + ], + enabledToolIds: [], + isAborted: () => false, + onThinkingDone: jest.fn(), + onFinalResponse: jest.fn(), + ...overrides, + }; +} + +describe('tool extension loop integration', () => { + beforeEach(() => { + resetStores(); + _clearExtensionsForTesting(); + jest.clearAllMocks(); + liteRTService.isModelLoaded.mockReturnValue(false); + llmService.isModelLoaded.mockReturnValue(true); + llmService.stopGeneration.mockResolvedValue(undefined); + }); + + describe('free path — no extensions registered', () => { + it('calls onFinalResponse with the LLM text', async () => { + llmService.generateResponseWithTools.mockResolvedValue({ + fullResponse: 'Hello world', + toolCalls: [], + }); + const ctx = makeCtx(); + await runToolLoop(ctx); + expect(ctx.onFinalResponse).toHaveBeenCalledWith('Hello world'); + }); + }); + + describe('pro path — extension registered', () => { + it('appends extension hint to the system prompt sent to LLM', async () => { + const executorMock = jest.fn().mockResolvedValue({ + name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, + } as ToolResult); + registerToolExtension(makeFakeExtension(executorMock)); + + // First call: model returns an MCP tool call tag; second: final answer + llmService.generateResponseWithTools + .mockResolvedValueOnce({ fullResponse: `${MCP_TOOL_NAME}`, toolCalls: [] }) + .mockResolvedValueOnce({ fullResponse: 'Done.', toolCalls: [] }); + + const ctx = makeCtx(); + await runToolLoop(ctx); + + // System prompt in the first LLM call should contain the extension hint + const firstCallMessages = llmService.generateResponseWithTools.mock.calls[0][0] as any[]; + const sysMsg = firstCallMessages.find((m: any) => m.role === 'system'); + expect(sysMsg.content).toContain(MCP_HINT); + }); + + it('routes execution to the extension executor, not built-in executeToolCall', async () => { + const executorMock = jest.fn().mockResolvedValue({ + name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, + } as ToolResult); + registerToolExtension(makeFakeExtension(executorMock)); + + llmService.generateResponseWithTools + .mockResolvedValueOnce({ fullResponse: `${MCP_TOOL_NAME}`, toolCalls: [] }) + .mockResolvedValueOnce({ fullResponse: 'Done.', toolCalls: [] }); + + const ctx = makeCtx(); + await runToolLoop(ctx); + + expect(executorMock).toHaveBeenCalledTimes(1); + expect(executorMock).toHaveBeenCalledWith( + expect.objectContaining({ name: MCP_TOOL_NAME }), + ); + + const { executeToolCall } = require('../../../src/services/tools'); + expect(executeToolCall).not.toHaveBeenCalled(); + }); + + it('stores tool result in chat store', async () => { + const executorMock = jest.fn().mockResolvedValue({ + name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, + } as ToolResult); + registerToolExtension(makeFakeExtension(executorMock)); + + llmService.generateResponseWithTools + .mockResolvedValueOnce({ fullResponse: `${MCP_TOOL_NAME}`, toolCalls: [] }) + .mockResolvedValueOnce({ fullResponse: 'Done.', toolCalls: [] }); + + const ctx = makeCtx(); + await runToolLoop(ctx); + + const messages = useChatStore.getState().conversations.find(c => c.id === ctx.conversationId)?.messages ?? []; + const toolResultMsg = messages.find(m => m.role === 'tool' && m.toolName === MCP_TOOL_NAME); + expect(toolResultMsg).toBeDefined(); + expect(toolResultMsg?.content).toBe(MCP_RESULT); + }); + + it('strips extension syntax from visible text', async () => { + const executorMock = jest.fn().mockResolvedValue({ + name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, + } as ToolResult); + registerToolExtension(makeFakeExtension(executorMock)); + + llmService.generateResponseWithTools + .mockResolvedValueOnce({ fullResponse: `Thinking...${MCP_TOOL_NAME}`, toolCalls: [] }) + .mockResolvedValueOnce({ fullResponse: 'Final answer.', toolCalls: [] }); + + const ctx = makeCtx(); + await runToolLoop(ctx); + + // The assistant message stored for the tool-call turn must not contain the raw tag + const messages = useChatStore.getState().conversations.find(c => c.id === ctx.conversationId)?.messages ?? []; + const assistantMsg = messages.find(m => m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0); + expect(assistantMsg?.content).not.toContain(''); + }); + }); +}); diff --git a/__tests__/unit/components/settings/sectionRegistry.test.ts b/__tests__/unit/components/settings/sectionRegistry.test.ts new file mode 100644 index 00000000..5ef5a517 --- /dev/null +++ b/__tests__/unit/components/settings/sectionRegistry.test.ts @@ -0,0 +1,33 @@ +import { + registerSettingsSection, + getSettingsSections, + _clearSectionsForTesting, +} from '../../../../src/components/settings/sectionRegistry'; + +const FakeSection = () => null; +const AnotherSection = () => null; + +describe('settings section registry', () => { + beforeEach(() => { + _clearSectionsForTesting(); + }); + + it('returns empty array when nothing registered', () => { + expect(getSettingsSections()).toEqual([]); + }); + + it('registers a section component', () => { + registerSettingsSection(FakeSection); + expect(getSettingsSections()).toHaveLength(1); + expect(getSettingsSections()[0]).toBe(FakeSection); + }); + + it('registers multiple sections in order', () => { + registerSettingsSection(FakeSection); + registerSettingsSection(AnotherSection); + const sections = getSettingsSections(); + expect(sections).toHaveLength(2); + expect(sections[0]).toBe(FakeSection); + expect(sections[1]).toBe(AnotherSection); + }); +}); diff --git a/__tests__/unit/navigation/screenRegistry.test.ts b/__tests__/unit/navigation/screenRegistry.test.ts new file mode 100644 index 00000000..976d55cb --- /dev/null +++ b/__tests__/unit/navigation/screenRegistry.test.ts @@ -0,0 +1,38 @@ +import { + registerScreen, + getRegisteredScreens, + _clearScreensForTesting, +} from '../../../src/navigation/screenRegistry'; + +const FakeScreen = () => null; +const AnotherScreen = () => null; + +describe('screen registry', () => { + beforeEach(() => { + _clearScreensForTesting(); + }); + + it('returns empty array when nothing registered', () => { + expect(getRegisteredScreens()).toEqual([]); + }); + + it('registers a screen', () => { + registerScreen({ name: 'McpServers', component: FakeScreen }); + expect(getRegisteredScreens()).toHaveLength(1); + expect(getRegisteredScreens()[0].name).toBe('McpServers'); + expect(getRegisteredScreens()[0].component).toBe(FakeScreen); + }); + + it('registers multiple screens', () => { + registerScreen({ name: 'McpServers', component: FakeScreen }); + registerScreen({ name: 'DebugLogs', component: AnotherScreen }); + expect(getRegisteredScreens()).toHaveLength(2); + expect(getRegisteredScreens().map(s => s.name)).toEqual(['McpServers', 'DebugLogs']); + }); + + it('allows duplicate names (no dedup — navigation handles it)', () => { + registerScreen({ name: 'McpServers', component: FakeScreen }); + registerScreen({ name: 'McpServers', component: AnotherScreen }); + expect(getRegisteredScreens()).toHaveLength(2); + }); +}); diff --git a/__tests__/unit/services/proLicenseService.test.ts b/__tests__/unit/services/proLicenseService.test.ts new file mode 100644 index 00000000..f8acc398 --- /dev/null +++ b/__tests__/unit/services/proLicenseService.test.ts @@ -0,0 +1,70 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + refreshProStatus, + isPro, + onProStatusChange, + _resetForTesting, +} from '../../../src/services/proLicenseService'; + +const mockedGetItem = AsyncStorage.getItem as jest.Mock; + +describe('proLicenseService', () => { + beforeEach(() => { + _resetForTesting(); + jest.clearAllMocks(); + }); + + describe('isPro()', () => { + it('returns false before refreshProStatus has been called', () => { + expect(isPro()).toBe(false); + }); + + it('returns false when no receipt is stored', async () => { + mockedGetItem.mockResolvedValueOnce(null); + await refreshProStatus(); + expect(isPro()).toBe(false); + }); + + it('returns false when stored receipt is too short (<= 10 chars)', async () => { + mockedGetItem.mockResolvedValueOnce('short'); + await refreshProStatus(); + expect(isPro()).toBe(false); + }); + + it('returns true when a valid receipt is stored', async () => { + mockedGetItem.mockResolvedValueOnce('valid-receipt-longer-than-ten-chars'); + await refreshProStatus(); + expect(isPro()).toBe(true); + }); + }); + + describe('refreshProStatus()', () => { + it('returns the resolved boolean', async () => { + mockedGetItem.mockResolvedValueOnce('valid-receipt-longer-than-ten-chars'); + const result = await refreshProStatus(); + expect(result).toBe(true); + }); + + it('notifies all listeners after refresh', async () => { + mockedGetItem.mockResolvedValue('valid-receipt-longer-than-ten-chars'); + const cb1 = jest.fn(); + const cb2 = jest.fn(); + onProStatusChange(cb1); + onProStatusChange(cb2); + await refreshProStatus(); + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + }); + }); + + describe('onProStatusChange()', () => { + it('returns an unsubscribe function that stops future notifications', async () => { + mockedGetItem.mockResolvedValue(null); + const cb = jest.fn(); + const unsub = onProStatusChange(cb); + unsub(); + await refreshProStatus(); + expect(cb).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/__tests__/unit/services/tools/extensions.test.ts b/__tests__/unit/services/tools/extensions.test.ts new file mode 100644 index 00000000..fea829d4 --- /dev/null +++ b/__tests__/unit/services/tools/extensions.test.ts @@ -0,0 +1,58 @@ +import { + registerToolExtension, + getToolExtensions, + _clearExtensionsForTesting, + ToolExtension, +} from '../../../../src/services/tools/extensions'; + +const makeExt = (id: string, toolCount = 0): ToolExtension => ({ + id, + getSystemPromptHint: () => `[hint:${id}]`, + parseToolCalls: () => [], + stripFromVisibleText: (text: string) => text, + canHandle: () => false, + execute: () => Promise.resolve({ name: id, content: '', durationMs: 0 }), + enabledToolCount: () => toolCount, +}); + +describe('tool extension registry', () => { + beforeEach(() => { + _clearExtensionsForTesting(); + }); + + it('returns empty array when no extensions registered', () => { + expect(getToolExtensions()).toEqual([]); + }); + + it('registers a single extension', () => { + const ext = makeExt('mcp'); + registerToolExtension(ext); + expect(getToolExtensions()).toHaveLength(1); + expect(getToolExtensions()[0].id).toBe('mcp'); + }); + + it('ignores duplicate registrations by id', () => { + const ext1 = makeExt('mcp'); + const ext2 = makeExt('mcp'); + registerToolExtension(ext1); + registerToolExtension(ext2); + expect(getToolExtensions()).toHaveLength(1); + expect(getToolExtensions()[0]).toBe(ext1); + }); + + it('allows multiple extensions with different ids', () => { + registerToolExtension(makeExt('mcp')); + registerToolExtension(makeExt('calendar')); + expect(getToolExtensions()).toHaveLength(2); + }); + + it('returns extension with correct interface', () => { + registerToolExtension(makeExt('mcp', 3)); + const [ext] = getToolExtensions(); + expect(ext.getSystemPromptHint()).toBe('[hint:mcp]'); + expect(ext.enabledToolCount()).toBe(3); + expect(ext.parseToolCalls('anything')).toEqual([]); + expect(ext.stripFromVisibleText('hello')).toBe('hello'); + expect(ext.canHandle('any_tool')).toBe(false); + }); +}); diff --git a/android/settings.gradle b/android/settings.gradle index 68aa09cb..c10b1ac2 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,6 +1,9 @@ pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } plugins { id("com.facebook.react.settings") } -extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } +extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> + // Use the login shell so nvm-managed node/npx is on PATH regardless of how Gradle was launched + ex.autolinkLibrariesFromCommand(["bash", "-l", "-c", "npx @react-native-community/cli config"]) +} rootProject.name = 'OffgridMobile' include ':app' includeBuild('../node_modules/@react-native/gradle-plugin') diff --git a/metro.config.js b/metro.config.js index 2a0a21ce..8bd09798 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,11 +1,28 @@ +const path = require('path'); +const fs = require('fs'); const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); -/** - * Metro configuration - * https://reactnative.dev/docs/metro - * - * @type {import('@react-native/metro-config').MetroConfig} - */ -const config = {}; +const proPackagePath = path.resolve(__dirname, '../offgrid-pro'); +const proStubPath = path.resolve(__dirname, 'src/bootstrap/proStub.js'); +const proExists = fs.existsSync(proPackagePath); + +const config = { + // Metro only watches the project root by default; files outside it (like the + // sibling @offgrid/pro package) must be listed here or require() will fail. + watchFolders: proExists ? [proPackagePath] : [], + resolver: { + // When resolving modules from outside the project root (i.e. @offgrid/pro), + // Metro falls back here so @babel/runtime and all other peer deps are found. + nodeModulesPaths: [path.resolve(__dirname, 'node_modules')], + extraNodeModules: { + // Exposes src/ as @offgrid/core so @offgrid/pro can import the design system, + // stores, and registries without a circular package dependency. + '@offgrid/core': path.resolve(__dirname, 'src'), + // Points to the real pro package when present on disk (store builds), + // falls back to a null stub so free builds bundle cleanly. + '@offgrid/pro': proExists ? proPackagePath : proStubPath, + }, + }, +}; module.exports = mergeConfig(getDefaultConfig(__dirname), config); diff --git a/src/bootstrap/loadProFeatures.ts b/src/bootstrap/loadProFeatures.ts new file mode 100644 index 00000000..f0e1e567 --- /dev/null +++ b/src/bootstrap/loadProFeatures.ts @@ -0,0 +1,31 @@ +import { refreshProStatus, onProStatusChange } from '../services/proLicenseService'; +import { registerToolExtension } from '../services/tools/extensions'; +import { registerScreen } from '../navigation/screenRegistry'; +import { registerSettingsSection } from '../components/settings/sectionRegistry'; + +export async function loadProFeatures(): Promise { + let pro: any; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + pro = require('@offgrid/pro'); + } catch (err) { + console.warn('[loadProFeatures] require(@offgrid/pro) threw:', err); + return; // free / contributor build: package not installed + } + if (!pro) { + console.warn('[loadProFeatures] @offgrid/pro resolved to null (stub build)'); + return; // proStub.js returns null — free build via metro extraNodeModules + } + + // Run synchronously before any await so screens are registered before the + // navigator renders (App.tsx doesn't await loadProFeatures). + const activate = () => { + pro.activate({ registerToolExtension, registerScreen, registerSettingsSection }); + }; + activate(); + console.log('[loadProFeatures] pro activated, registered screens'); + + // Async: refresh receipt so pro-gated UI can react. + await refreshProStatus(); + onProStatusChange(activate); +} diff --git a/src/bootstrap/proStub.js b/src/bootstrap/proStub.js new file mode 100644 index 00000000..6ff63fd5 --- /dev/null +++ b/src/bootstrap/proStub.js @@ -0,0 +1,3 @@ +// Free-build stub. Metro resolves @offgrid/pro here when ../offgrid-pro is absent. +// loadProFeatures.ts checks for null and skips activation. +module.exports = null; diff --git a/src/components/ChatInput/Popovers.tsx b/src/components/ChatInput/Popovers.tsx index 2aa4e7ca..76842fdd 100644 --- a/src/components/ChatInput/Popovers.tsx +++ b/src/components/ChatInput/Popovers.tsx @@ -70,6 +70,8 @@ interface QuickSettingsPopoverProps { supportsToolCalling: boolean; enabledToolCount: number; onToolsPress?: () => void; + mcpToolCount?: number; + onMcpPress?: () => void; } function getImageModeBadge(mode: ImageModeState, colors: any) { @@ -99,6 +101,7 @@ export const QuickSettingsPopover: React.FC = ({ visible, onClose, anchorY, anchorX, imageMode, onImageModeToggle, imageModelLoaded, supportsThinking, supportsToolCalling, enabledToolCount, onToolsPress, + mcpToolCount = 0, onMcpPress, }) => { const { colors } = useTheme(); const { settings, updateSettings, toolCountHintDismissed } = useAppStore(); @@ -170,6 +173,22 @@ export const QuickSettingsPopover: React.FC = ({ {tools.badgeLabel} + + { + triggerHaptic('impactLight'); + onClose(); + onMcpPress?.(); + }} + > + 0 ? colors.primary : colors.textMuted} /> + MCP + 0 ? colors.primary : colors.textMuted }]}> + {mcpToolCount} + + diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx index 6c90940a..716a81d1 100644 --- a/src/components/ChatInput/index.tsx +++ b/src/components/ChatInput/index.tsx @@ -31,6 +31,8 @@ interface ChatInputProps { onToolsPress?: () => void; enabledToolCount?: number; supportsToolCalling?: boolean; + mcpToolCount?: number; + onMcpPress?: () => void; supportsThinking?: boolean; onRepairVision?: () => void; /** When set, mounts a single AttachStep for that index. Only one at a time to avoid waypoint dots. */ @@ -60,6 +62,8 @@ export const ChatInput: React.FC = ({ enabledToolCount = 0, supportsToolCalling = false, supportsThinking = false, + mcpToolCount = 0, + onMcpPress, onRepairVision, activeSpotlight = null, showSettingsDot = false, @@ -292,6 +296,8 @@ export const ChatInput: React.FC = ({ supportsToolCalling={supportsToolCalling} enabledToolCount={enabledToolCount} onToolsPress={onToolsPress} + mcpToolCount={mcpToolCount} + onMcpPress={onMcpPress} /> [] = []; + +export function registerSettingsSection(component: ComponentType): void { + sections.push(component); +} + +export function getSettingsSections(): ComponentType[] { + return sections; +} + +export function _clearSectionsForTesting(): void { + sections.length = 0; +} diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index fbbc4322..3f28f3dd 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -44,6 +44,7 @@ import { RootStackParamList, MainTabParamList, } from './types'; +import { getRegisteredScreens } from './screenRegistry'; const RootStack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); @@ -254,6 +255,9 @@ export const AppNavigator: React.FC = () => { component={GalleryScreen} options={{ presentation: 'modal', animation: 'slide_from_bottom' }} /> + {getRegisteredScreens().map(s => ( + + ))} ); diff --git a/src/navigation/screenRegistry.ts b/src/navigation/screenRegistry.ts new file mode 100644 index 00000000..c9f15986 --- /dev/null +++ b/src/navigation/screenRegistry.ts @@ -0,0 +1,20 @@ +import type { ComponentType } from 'react'; + +export interface RegisteredScreen { + name: string; + component: ComponentType; +} + +const screens: RegisteredScreen[] = []; + +export function registerScreen(screen: RegisteredScreen): void { + screens.push(screen); +} + +export function getRegisteredScreens(): RegisteredScreen[] { + return screens; +} + +export function _clearScreensForTesting(): void { + screens.length = 0; +} diff --git a/src/screens/ChatScreen/ChatMessageArea.tsx b/src/screens/ChatScreen/ChatMessageArea.tsx index bd69d3b4..854de725 100644 --- a/src/screens/ChatScreen/ChatMessageArea.tsx +++ b/src/screens/ChatScreen/ChatMessageArea.tsx @@ -13,6 +13,8 @@ import { getPlaceholderText, useChatScreen } from './useChatScreen'; import { createStyles } from './styles'; import { useTheme } from '../../theme'; import { useAppStore } from '../../stores'; +import { getToolExtensions } from '../../services/tools/extensions'; +import { getRegisteredScreens } from '../../navigation/screenRegistry'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { RootStackParamList } from '../../navigation/types'; @@ -33,7 +35,17 @@ export const ChatMessageArea: React.FC = ({ }) => { const tabNav = useNavigation>(); const { toolCountHintDismissed } = useAppStore(); - const showSettingsDot = chat.enabledTools.length > 3 && !toolCountHintDismissed; + const extToolCount = getToolExtensions().reduce((n, e) => n + e.enabledToolCount(), 0); + const totalToolCount = chat.enabledTools.length + extToolCount; + const handleMcpPress = () => { + const hasMcpScreen = getRegisteredScreens().some(s => s.name === 'McpServers'); + if (hasMcpScreen) { + tabNav.navigate('McpServers' as any); + } else { + tabNav.navigate('ProDetail'); + } + }; + const showSettingsDot = totalToolCount > 3 && !toolCountHintDismissed; const [inputHeight, setInputHeight] = useState(84); const flatListHeightRef = useRef(0); const isStreaming = chat.isStreaming || chat.isThinking; @@ -157,8 +169,10 @@ export const ChatMessageArea: React.FC = ({ imageOnly: chat.imageModelLoaded && !chat.hasTextModel, })} onToolsPress={() => chat.setShowToolPicker(true)} - enabledToolCount={chat.enabledTools.length} + enabledToolCount={totalToolCount} showSettingsDot={showSettingsDot} + mcpToolCount={extToolCount} + onMcpPress={handleMcpPress} supportsToolCalling={chat.supportsToolCalling} supportsThinking={chat.supportsThinking} onRepairVision={handleRepairVision} diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index 9c15769b..8ac949e7 100644 --- a/src/screens/ChatScreen/useChatGenerationActions.ts +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -18,6 +18,7 @@ import { retrievalService, } from '../../services'; import { liteRTService } from '../../services/litert'; +import { getToolExtensions } from '../../services/tools/extensions'; import { embeddingService } from '../../services/rag/embedding'; import { useChatStore, useProjectStore, useRemoteServerStore } from '../../stores'; import { Message, MediaAttachment, Project, DownloadedModel, RemoteModel, ModelLoadingStrategy, CacheType } from '../../types'; @@ -180,7 +181,8 @@ async function generateWithCompactionRetry( enabledTools: string[], projectId?: string, ): Promise { - const gen = (msgs: Message[]) => enabledTools.length > 0 + const extCount = getToolExtensions().reduce((n, e) => n + e.enabledToolCount(), 0); + const gen = (msgs: Message[]) => (enabledTools.length > 0 || extCount > 0) ? generationService.generateWithTools(opts.id, msgs, { enabledToolIds: enabledTools, projectId }) : generationService.generateResponse(opts.id, msgs); try { await gen(opts.messages); } catch (error: any) { diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 8eb46099..eae10746 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -30,6 +30,7 @@ import { hardwareService } from '../services'; import { RootStackParamList, MainTabParamList } from '../navigation/types'; import { GITHUB_URL, SHARE_ON_X_URL } from '../utils/sharePrompt'; import packageJson from '../../package.json'; +import { getSettingsSections } from '../components/settings/sectionRegistry'; const FEEDBACK_EMAIL = 'support@offgridmobile.co'; @@ -250,6 +251,9 @@ export const SettingsScreen: React.FC = () => { + {/* Pro feature sections registered at runtime by @offgrid/pro */} + {getSettingsSections().map((Section, i) =>
)} + {/* Community */} diff --git a/src/services/generationToolLoop.ts b/src/services/generationToolLoop.ts index fb974f23..20ac96b5 100644 --- a/src/services/generationToolLoop.ts +++ b/src/services/generationToolLoop.ts @@ -7,6 +7,7 @@ import { useChatStore, useRemoteServerStore, useAppStore } from '../stores'; import { Message } from '../types'; import { getToolsAsOpenAISchema, executeToolCall } from './tools'; import type { ToolCall, ToolResult } from './tools/types'; +import { getToolExtensions } from './tools/extensions'; import { providerRegistry } from './providers'; import type { GenerationOptions, CompletionResult } from './providers/types'; import logger from '../utils/logger'; @@ -193,6 +194,7 @@ function getLastUserQuery(messages: Message[]): string { } async function executeToolCalls(ctx: ToolLoopContext, toolCalls: import('./tools/types').ToolCall[], loopMessages: Message[]): Promise { const chatStore = useChatStore.getState(); + const exts = getToolExtensions(); for (const tc of toolCalls) { if (ctx.isAborted()) break; // Small models often call web_search with empty args — use user's message as fallback @@ -206,7 +208,8 @@ async function executeToolCalls(ctx: ToolLoopContext, toolCalls: import('./tools logger.log(`[ToolLoop][DEBUG] Executing tool: ${tc.name}, args: ${JSON.stringify(tc.arguments).substring(0, 200)}`); if (ctx.projectId) tc.context = { projectId: ctx.projectId }; ctx.callbacks?.onToolCallStart?.(tc.name, tc.arguments); - const result = await executeToolCall(tc); + const ext = exts.find(e => e.canHandle(tc.name)); + const result = ext ? await ext.execute(tc) : await executeToolCall(tc); logger.log(`[ToolLoop][DEBUG] Tool ${tc.name} result: error=${result.error || 'none'}, content length=${result.content?.length || 0}, duration=${result.durationMs}ms`); ctx.callbacks?.onToolCallComplete?.(tc.name, result); const toolResultMsg: Message = { @@ -405,7 +408,8 @@ function augmentSystemPromptForTools(messages: Message[]): Message[] { if (sysIdx === -1) return messages; const sys = messages[sysIdx]; const existing = typeof sys.content === 'string' ? sys.content : ''; - const updated = { ...sys, content: existing + TOOL_BEHAVIOR_GUIDANCE }; + const extHints = getToolExtensions().map(e => e.getSystemPromptHint()).filter(Boolean).join(''); + const updated = { ...sys, content: existing + TOOL_BEHAVIOR_GUIDANCE + extHints }; return [...messages.slice(0, sysIdx), updated, ...messages.slice(sysIdx + 1)]; } @@ -419,8 +423,11 @@ async function callLLMWithRetry( ): Promise<{ fullResponse: string; toolCalls: ToolCall[] }> { // Append tool-use behavioral guidance to the system prompt when tools are present. // Only covers the "when and how" — schemas are injected separately by each engine. + // Also append extension system-prompt hints so the model knows about MCP/pro tools. // We shallow-copy messages to avoid mutating the caller's array. - const augmentedMessages = tools.length > 0 ? augmentSystemPromptForTools(messages) : messages; + const exts = getToolExtensions(); + const extCount = exts.reduce((n, e) => n + e.enabledToolCount(), 0); + const augmentedMessages = (tools.length > 0 || extCount > 0) ? augmentSystemPromptForTools(messages) : messages; if (isLiteRTActive() && conversationId) { return callLiteRTForLoop(conversationId, augmentedMessages, { tools, onStream, ctx }); @@ -435,24 +442,41 @@ async function callLLMWithRetry( return callLocalWithRetry(augmentedMessages, tools, onStream); } -/** If no structured tool calls, try parsing tags or Gemma's native format from text. */ +/** If no structured tool calls, try parsing tags or Gemma's native format from text. + * Also collects tool calls from any registered extensions and strips their syntax from display text. */ function resolveToolCalls(fullResponse: string, toolCalls: ToolCall[]) { - if (toolCalls.length > 0) return { effectiveToolCalls: toolCalls, displayResponse: fullResponse }; - if (fullResponse.includes('')) { - const parsed = parseToolCallsFromText(fullResponse); - if (parsed.toolCalls.length > 0) { - logger.log(`[ToolLoop] Parsed ${parsed.toolCalls.length} tool call(s) from tags`); - return { effectiveToolCalls: parsed.toolCalls, displayResponse: parsed.cleanText }; + let effectiveToolCalls: ToolCall[] = toolCalls.length > 0 ? [...toolCalls] : []; + let displayResponse = fullResponse; + + if (effectiveToolCalls.length === 0) { + if (fullResponse.includes('')) { + const parsed = parseToolCallsFromText(fullResponse); + if (parsed.toolCalls.length > 0) { + logger.log(`[ToolLoop] Parsed ${parsed.toolCalls.length} tool call(s) from tags`); + effectiveToolCalls = parsed.toolCalls; + displayResponse = parsed.cleanText; + } + } else if (fullResponse.includes('<|tool_call>') || fullResponse.includes(' 0) { + logger.log(`[ToolLoop] Parsed ${parsed.toolCalls.length} tool call(s) from Gemma native format`); + effectiveToolCalls = parsed.toolCalls; + displayResponse = parsed.cleanText; + } } } - if (fullResponse.includes('<|tool_call>') || fullResponse.includes(' 0) { - logger.log(`[ToolLoop] Parsed ${parsed.toolCalls.length} tool call(s) from Gemma native format`); - return { effectiveToolCalls: parsed.toolCalls, displayResponse: parsed.cleanText }; + + // Parse extension tool calls and strip their syntax from the visible text + for (const ext of getToolExtensions()) { + const extCalls = ext.parseToolCalls(displayResponse); + if (extCalls.length > 0) { + logger.log(`[ToolLoop] Extension ${ext.id} parsed ${extCalls.length} tool call(s)`); + effectiveToolCalls.push(...extCalls); } + displayResponse = ext.stripFromVisibleText(displayResponse); } - return { effectiveToolCalls: toolCalls, displayResponse: fullResponse }; + + return { effectiveToolCalls, displayResponse }; } interface ToolLoopState { diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts new file mode 100644 index 00000000..8b0e0df3 --- /dev/null +++ b/src/services/proLicenseService.ts @@ -0,0 +1,35 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const KEY = 'user_pro_receipt'; +let cached: boolean | null = null; +const listeners = new Set<() => void>(); + +export async function refreshProStatus(): Promise { + cached = await validateStoredReceipt(); + listeners.forEach(l => l()); + return cached; +} + +export function isPro(): boolean { + return cached === true; +} + +export function onProStatusChange( cb: () => void): () => void { + listeners.add(cb); + return () => listeners.delete(cb); +} + +// TODO: remove before shipping — bypasses receipt check for local dev +const DEV_BYPASS_PRO = __DEV__; + +// production: validate the Apple/Google receipt signature (react-native-iap) +async function validateStoredReceipt(): Promise { + if (DEV_BYPASS_PRO) return true; + const raw = await AsyncStorage.getItem(KEY); + return !!raw && raw.length > 10; +} + +export function _resetForTesting(): void { + cached = null; + listeners.clear(); +} diff --git a/src/services/tools/extensions.ts b/src/services/tools/extensions.ts new file mode 100644 index 00000000..12e20bc1 --- /dev/null +++ b/src/services/tools/extensions.ts @@ -0,0 +1,25 @@ +import type { ToolCall, ToolResult } from './types'; + +export interface ToolExtension { + id: string; + getSystemPromptHint(): string; + parseToolCalls(text: string): ToolCall[]; + stripFromVisibleText(text: string): string; + canHandle(toolName: string): boolean; + execute(call: ToolCall): Promise; + enabledToolCount(): number; +} + +const extensions: ToolExtension[] = []; + +export function registerToolExtension(ext: ToolExtension): void { + if (!extensions.find(e => e.id === ext.id)) extensions.push(ext); +} + +export function getToolExtensions(): ToolExtension[] { + return extensions; +} + +export function _clearExtensionsForTesting(): void { + extensions.length = 0; +} From d7d720d72f135d59b4ef3e0c7e773ba067d4a3af Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 10 Jun 2026 12:25:44 +0530 Subject: [PATCH 02/28] chore: vendor pro package as private submodule at pro/ - add private-offgrid as a git submodule at pro/ - repoint metro @offgrid/pro resolver from ../offgrid-pro sibling to ./pro - detect populated submodule via pro/package.json (empty dir when not checked out) Co-Authored-By: Dishit Karia --- .gitmodules | 3 +++ metro.config.js | 12 +++++++----- pro | 1 + 3 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 .gitmodules create mode 160000 pro diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..1a8f7c5c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "pro"] + path = pro + url = https://github.com/dishit-wednesday/private-offgrid.git diff --git a/metro.config.js b/metro.config.js index 8bd09798..6cd078f2 100644 --- a/metro.config.js +++ b/metro.config.js @@ -2,14 +2,16 @@ const path = require('path'); const fs = require('fs'); const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); -const proPackagePath = path.resolve(__dirname, '../offgrid-pro'); +const proPackagePath = path.resolve(__dirname, 'pro'); const proStubPath = path.resolve(__dirname, 'src/bootstrap/proStub.js'); -const proExists = fs.existsSync(proPackagePath); +// pro/ is a git submodule: the directory exists even when not checked out, so test +// for a real file inside it (package.json) to detect a populated submodule. +const proExists = fs.existsSync(path.resolve(proPackagePath, 'package.json')); const config = { - // Metro only watches the project root by default; files outside it (like the - // sibling @offgrid/pro package) must be listed here or require() will fail. - watchFolders: proExists ? [proPackagePath] : [], + // pro/ is a submodule inside the project root, so Metro already watches it by + // default; nothing extra needed here. (When absent it's just an empty dir.) + watchFolders: [], resolver: { // When resolving modules from outside the project root (i.e. @offgrid/pro), // Metro falls back here so @babel/runtime and all other peer deps are found. diff --git a/pro b/pro new file mode 160000 index 00000000..5d81a87d --- /dev/null +++ b/pro @@ -0,0 +1 @@ +Subproject commit 5d81a87d5a29282865c1cf456313addbf1e35c12 From d76a5a61d7459458bd147f353fbd67d0d8771038 Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 10 Jun 2026 12:48:55 +0530 Subject: [PATCH 03/28] bump pro --- pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pro b/pro index 5d81a87d..028e97ae 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 5d81a87d5a29282865c1cf456313addbf1e35c12 +Subproject commit 028e97ae82a3cccb1c006dd8c0dd558d754b1f2e From 32ebc071dcece70f09466d9e924e66d907137b8f Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 10 Jun 2026 13:13:42 +0530 Subject: [PATCH 04/28] test: fix proLicenseService dev-bypass under jest; load toolExtensionLoop suite Co-Authored-By: Dishit Karia --- .../integration/generation/toolExtensionLoop.test.ts | 11 ++++++++--- src/services/proLicenseService.ts | 5 +++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/__tests__/integration/generation/toolExtensionLoop.test.ts b/__tests__/integration/generation/toolExtensionLoop.test.ts index 282d1eb8..4d8166cf 100644 --- a/__tests__/integration/generation/toolExtensionLoop.test.ts +++ b/__tests__/integration/generation/toolExtensionLoop.test.ts @@ -33,11 +33,16 @@ jest.mock('../../../src/services/tools', () => ({ executeToolCall: jest.fn().mockResolvedValue({ name: 'builtin', content: 'builtin-result', durationMs: 1 }), })); -// Mock remote server store so we stay on the local path +// Mock the stores index: pull in the REAL chat + app stores (the loop reads/writes +// them and the test asserts on stored messages), stub the remote server store so we +// stay on the local path. Requiring the submodules directly avoids loading the full +// stores index (auth/whisper/project), which fails to initialise in this test env. jest.mock('../../../src/stores', () => { - const actual = jest.requireActual('../../../src/stores'); + const { useChatStore } = jest.requireActual('../../../src/stores/chatStore'); + const { useAppStore } = jest.requireActual('../../../src/stores/appStore'); return { - ...actual, + useChatStore, + useAppStore, useRemoteServerStore: { getState: () => ({ activeServerId: null, activeRemoteTextModelId: null }), }, diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts index 8b0e0df3..7bcfbd89 100644 --- a/src/services/proLicenseService.ts +++ b/src/services/proLicenseService.ts @@ -19,8 +19,9 @@ export function onProStatusChange( cb: () => void): () => void { return () => listeners.delete(cb); } -// TODO: remove before shipping — bypasses receipt check for local dev -const DEV_BYPASS_PRO = __DEV__; +// TODO: remove before shipping — bypasses receipt check for local dev. +// Disabled under Jest so the real receipt-validation logic is exercised by tests. +const DEV_BYPASS_PRO = __DEV__ && process.env.JEST_WORKER_ID === undefined; // production: validate the Apple/Google receipt signature (react-native-iap) async function validateStoredReceipt(): Promise { From 82ada8a2504293d8c0ca6715f28991289da2c277 Mon Sep 17 00:00:00 2001 From: Dishit Date: Wed, 10 Jun 2026 17:40:30 +0530 Subject: [PATCH 05/28] feat: wire debug log viewer, fix MCP tool hint injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire logger.ts → debugLogsStore via setLogListener so JS logs are visible in-app (no more RNFS writes; in-memory ring buffer, 500 entries) - Add Debug Logs button in Settings dev section - Fix critical bug: MCP (and any ToolExtension) system prompt hints were never injected into the generation prompt; models had no idea tools existed. Now both send and regen paths call getSystemPromptHint() on every registered extension. - Simplify debugLogsStore to in-memory only (removes AsyncStorage dep) - Remove stale loadFromStorage call from DebugLogsScreen - Exclude pro/** from root tsconfig so it compiles under its own config - Bump pro submodule to include MCP diagnostic logging commit Co-Authored-By: Dishit Karia --- App.tsx | 10 ++++-- pro | 2 +- src/components/DebugLogsScreen/index.tsx | 8 ++--- .../ChatScreen/useChatGenerationActions.ts | 23 ++++++++++--- src/screens/SettingsScreen.tsx | 14 ++++++-- src/stores/debugLogsStore.ts | 33 +++++-------------- src/utils/logger.ts | 12 +++++++ tsconfig.json | 2 +- 8 files changed, 63 insertions(+), 41 deletions(-) diff --git a/App.tsx b/App.tsx index eb65294a..d66d8827 100644 --- a/App.tsx +++ b/App.tsx @@ -12,17 +12,21 @@ import { NavigationContainer } from '@react-navigation/native'; import { AppNavigator } from './src/navigation'; import { useTheme } from './src/theme'; import { hardwareService, modelManager, authService, ragService, remoteServerManager } from './src/services'; -import logger from './src/utils/logger'; +import logger, { setLogListener } from './src/utils/logger'; import { useAppStore, useAuthStore, useRemoteServerStore } from './src/stores'; +import { useDebugLogsStore } from './src/stores/debugLogsStore'; +import { loadProFeatures } from './src/bootstrap/loadProFeatures'; import { hydrateDownloadStore } from './src/services/downloadHydration'; import { useDownloadListeners } from './src/hooks/useDownloads'; import { LockScreen } from './src/screens'; import { useAppState } from './src/hooks/useAppState'; import { useDownloadStore } from './src/stores/downloadStore'; -import { loadProFeatures } from './src/bootstrap/loadProFeatures'; LogBox.ignoreAllLogs(); // Suppress all logs +// Wire logger → in-app debug viewer (runs before any component mounts) +setLogListener((entry) => useDebugLogsStore.getState().addLog(entry)); + const ensureRemoteServerStoreHydrated = async () => { const persistApi = useRemoteServerStore.persist; if (!persistApi?.hasHydrated || !persistApi.rehydrate) return; @@ -167,7 +171,7 @@ function App() { // Initialize RAG database tables ragService.ensureReady().catch((err) => logger.error('Failed to initialize RAG service on startup', err)); - // Load pro features if @offgrid/pro is installed and receipt is valid + // Load pro features synchronously (screens registered before AppNavigator renders) loadProFeatures().catch((err) => logger.error('[App] loadProFeatures failed:', err)); // Show the UI immediately diff --git a/pro b/pro index 028e97ae..81ecf7e4 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 028e97ae82a3cccb1c006dd8c0dd558d754b1f2e +Subproject commit 81ecf7e4da58ad65eb816e53b102fe9c0dca5ae8 diff --git a/src/components/DebugLogsScreen/index.tsx b/src/components/DebugLogsScreen/index.tsx index 94c4d13c..a9966b9e 100644 --- a/src/components/DebugLogsScreen/index.tsx +++ b/src/components/DebugLogsScreen/index.tsx @@ -3,7 +3,7 @@ * Simple modal showing captured debug logs with copy and clear options */ -import React, { useEffect } from 'react'; +import React from 'react'; import { View, Text, @@ -27,11 +27,7 @@ interface DebugLogsScreenProps { export const DebugLogsScreen: React.FC = ({ visible, onClose }) => { const theme = useTheme(); const styles = useThemedStyles(createStyles); - const { logs, clearLogs, loadFromStorage } = useDebugLogsStore() as any; // NOSONAR - zustand store - - useEffect(() => { - loadFromStorage(); - }, []); + const { logs, clearLogs } = useDebugLogsStore(); const formatTime = (timestamp: number) => { const date = new Date(timestamp); diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index 8ac949e7..d1917ee8 100644 --- a/src/screens/ChatScreen/useChatGenerationActions.ts +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -17,8 +17,8 @@ import { ragService, retrievalService, } from '../../services'; -import { liteRTService } from '../../services/litert'; import { getToolExtensions } from '../../services/tools/extensions'; +import { liteRTService } from '../../services/litert'; import { embeddingService } from '../../services/rag/embedding'; import { useChatStore, useProjectStore, useRemoteServerStore } from '../../stores'; import { Message, MediaAttachment, Project, DownloadedModel, RemoteModel, ModelLoadingStrategy, CacheType } from '../../types'; @@ -276,12 +276,24 @@ export async function startGenerationFn(deps: GenerationDeps, call: StartGenerat // LiteRT passes tools natively via ConversationConfig — text hint would double-inject. // llama.cpp uses text hint only when it lacks native Jinja tool calling support. const useTextHint = !isRemote && !isLiteRT && activeTools.length > 0 && !llmService.supportsToolCalling(); + + // Collect extension hints (MCP tools etc.) and append when using text-hint mode + const extensions = getToolExtensions(); + const extHints = extensions.map(e => e.getSystemPromptHint()).filter(Boolean); + logger.log(`[ChatGen][EXT] ${extensions.length} extension(s) registered, ${extHints.length} with non-empty hints`); + if (extHints.length > 0) { + logger.log(`[ChatGen][EXT] hints: ${extHints.map((h, i) => `ext[${i}]=${h.length}ch`).join(', ')}`); + } + const extHintBlock = extHints.join(''); + const systemPrompt = applyGemma4ThinkToken( - useTextHint ? `${basePrompt}${buildToolSystemPromptHint(activeTools)}` : basePrompt, + useTextHint + ? `${basePrompt}${buildToolSystemPromptHint(activeTools)}${extHintBlock}` + : `${basePrompt}${extHintBlock}`, isRemote, { isLiteRT, thinkingEnabled: deps.settings.thinkingEnabled }, ); - logger.log(`[ChatGen][DEBUG] isRemote=${isRemote}, isLiteRT=${isLiteRT}, useTextHint=${useTextHint}, tools=[${activeTools.join(', ')}], path=${activeTools.length > 0 ? 'withTools' : 'generate'}`); + logger.log(`[ChatGen][DEBUG] isRemote=${isRemote}, isLiteRT=${isLiteRT}, useTextHint=${useTextHint}, tools=[${activeTools.join(', ')}], extHints=${extHints.length}`); logger.log(`[ChatGen][PROMPT] systemPrompt (${systemPrompt.length}ch): "${systemPrompt.substring(0, 800)}"`); const messagesForContext = buildMessagesForContext(targetConversationId, messageText, systemPrompt); await prepareContext(setDebugInfo, systemPrompt, messagesForContext); @@ -371,8 +383,11 @@ export async function regenerateResponseFn(deps: GenerationDeps, call: Regenerat const activeTools = enabledTools; const basePrompt = await injectRagContext(conversation?.projectId, messageText, rawPrompt); const useTextHint = !isRemote && !isLiteRTRegen && activeTools.length > 0 && !llmService.supportsToolCalling(); + const regenExtHints = getToolExtensions().map(e => e.getSystemPromptHint()).filter(Boolean).join(''); const systemPrompt = applyGemma4ThinkToken( - useTextHint ? `${basePrompt}${buildToolSystemPromptHint(activeTools)}` : basePrompt, + useTextHint + ? `${basePrompt}${buildToolSystemPromptHint(activeTools)}${regenExtHints}` + : `${basePrompt}${regenExtHints}`, isRemote, { isLiteRT: isLiteRTRegen, thinkingEnabled: deps.settings.thinkingEnabled }, ); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index eae10746..c398608e 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { View, Text, @@ -19,6 +19,8 @@ import { Card } from '../components'; import { AnimatedEntry } from '../components/AnimatedEntry'; import { AnimatedListItem } from '../components/AnimatedListItem'; import { MadeWithLove } from '../components/MadeWithLove'; +import { DebugLogsScreen } from '../components/DebugLogsScreen'; +import { getSettingsSections } from '../components/settings/sectionRegistry'; import { useFocusTrigger } from '../hooks/useFocusTrigger'; import { useTheme, useThemedStyles } from '../theme'; import type { ThemeColors, ThemeShadows } from '../theme'; @@ -30,7 +32,6 @@ import { hardwareService } from '../services'; import { RootStackParamList, MainTabParamList } from '../navigation/types'; import { GITHUB_URL, SHARE_ON_X_URL } from '../utils/sharePrompt'; import packageJson from '../../package.json'; -import { getSettingsSections } from '../components/settings/sectionRegistry'; const FEEDBACK_EMAIL = 'support@offgridmobile.co'; @@ -49,6 +50,7 @@ export const SettingsScreen: React.FC = () => { const setThemeMode = useAppStore((s) => s.setThemeMode); const completeChecklistStep = useAppStore((s) => s.completeChecklistStep); const resetChecklist = useAppStore((s) => s.resetChecklist); + const [showDebugLogs, setShowDebugLogs] = useState(false); const deviceInfo = useAppStore((s) => s.deviceInfo); const showProBanner = useAppStore((s) => !s.proBannerDismissed); const setProBannerDismissed = useAppStore((s) => s.setProBannerDismissed); @@ -320,6 +322,9 @@ export const SettingsScreen: React.FC = () => { + {/* Pro feature sections registered at runtime by @offgrid/pro */} + {getSettingsSections().map((Section, i) =>
)} + {/* Reset Onboarding */} @@ -331,9 +336,14 @@ export const SettingsScreen: React.FC = () => { Reset Onboarding Checklist + setShowDebugLogs(true)}> + + Debug Logs + + setShowDebugLogs(false)} /> ); diff --git a/src/stores/debugLogsStore.ts b/src/stores/debugLogsStore.ts index 7a20e50c..879cb467 100644 --- a/src/stores/debugLogsStore.ts +++ b/src/stores/debugLogsStore.ts @@ -1,7 +1,6 @@ import { create } from 'zustand'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -const STORAGE_KEY = '@debug_logs'; +const MAX_IN_MEMORY = 500; export interface DebugLogEntry { timestamp: number; @@ -11,30 +10,16 @@ export interface DebugLogEntry { interface DebugLogsState { logs: DebugLogEntry[]; - loaded: boolean; + addLog: (entry: DebugLogEntry) => void; clearLogs: () => void; - loadFromStorage: () => Promise; } -export const useDebugLogsStore = create((set, get) => ({ +export const useDebugLogsStore = create((set) => ({ logs: [], - loaded: false, - clearLogs: () => { - set({ logs: [] }); - AsyncStorage.removeItem(STORAGE_KEY).catch(() => {}); - }, - loadFromStorage: async () => { - if (get().loaded) return; - try { - const raw = await AsyncStorage.getItem(STORAGE_KEY); - if (raw) { - const logs: DebugLogEntry[] = JSON.parse(raw); - set({ logs, loaded: true }); - } else { - set({ loaded: true }); - } - } catch { - set({ loaded: true }); - } - }, + addLog: (entry) => set((state) => ({ + logs: state.logs.length >= MAX_IN_MEMORY + ? [...state.logs.slice(-(MAX_IN_MEMORY - 1)), entry] + : [...state.logs, entry], + })), + clearLogs: () => set({ logs: [] }), })); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index f17f31ae..1c0d1ce7 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -6,6 +6,13 @@ const RETAINED_LOG_LINES = 4000; let writeQueue = Promise.resolve(); +type LogListener = (entry: { timestamp: number; level: 'log' | 'warn' | 'error'; message: string }) => void; +let _logListener: LogListener | null = null; + +export function setLogListener(fn: LogListener): void { + _logListener = fn; +} + function getLogFilePath(): string { return `${RNFS.DocumentDirectoryPath}/${LOG_FILE_NAME}`; } @@ -51,6 +58,11 @@ function appendPersistentLog(level: 'log' | 'warn' | 'error', args: unknown[]): function capture(level: 'log' | 'warn' | 'error', args: unknown[]): void { appendPersistentLog(level, args); + if (_logListener) { + try { + _logListener({ timestamp: Date.now(), level, message: args.map(formatArg).join(' ') }); + } catch { /* listener must never break logging */ } + } } const logger = { diff --git a/tsconfig.json b/tsconfig.json index df3bf6f6..efd02be9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,5 +4,5 @@ "types": ["jest", "node"], }, "include": ["**/*.ts", "**/*.tsx"], - "exclude": ["**/node_modules", "**/Pods"] + "exclude": ["**/node_modules", "**/Pods", "pro/**"] } From 0500bcdc9358838f485a46429842f18df847058f Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 11 Jun 2026 17:21:05 +0530 Subject: [PATCH 06/28] feat: add calendar tools with native permissions and null guards - Add READ_CALENDAR and WRITE_CALENDAR permissions to AndroidManifest - Add NSCalendarsUsageDescription to iOS Info.plist - Add react-native-add-calendar-event and react-native-calendar-events packages - Update Podfile.lock with iOS calendar pod dependencies - Simplify settings.gradle autolinkLibrariesFromCommand call - Add null guard in handlers.ts: throws descriptive error if calendar native module unavailable Co-Authored-By: Dishit Karia --- android/app/src/main/AndroidManifest.xml | 4 ++ android/settings.gradle | 5 +- ios/OffgridMobile/Info.plist | 2 + ios/Podfile.lock | 16 +++++- package-lock.json | 26 +++++++++ package.json | 2 + src/services/tools/handlers.ts | 72 +++++++++++++++++++++++- 7 files changed, 120 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 69f1e4be..ea8f0cf8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,10 @@ + + + + - // Use the login shell so nvm-managed node/npx is on PATH regardless of how Gradle was launched - ex.autolinkLibrariesFromCommand(["bash", "-l", "-c", "npx @react-native-community/cli config"]) -} +extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } rootProject.name = 'OffgridMobile' include ':app' includeBuild('../node_modules/@react-native/gradle-plugin') diff --git a/ios/OffgridMobile/Info.plist b/ios/OffgridMobile/Info.plist index 25d781d5..0a2816f2 100644 --- a/ios/OffgridMobile/Info.plist +++ b/ios/OffgridMobile/Info.plist @@ -49,6 +49,8 @@ This app needs permission to save generated images to your photo library. NSPhotoLibraryUsageDescription This app needs access to your photo library to attach images to conversations. + NSCalendarsUsageDescription + Used to read and create calendar events on your request. NSSpeechRecognitionUsageDescription This app uses on-device speech recognition to transcribe voice input. RCTNewArchEnabled diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cac5f375..dc97e1c8 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1982,6 +1982,8 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket + - react-native-add-calendar-event (5.0.0): + - React-Core - react-native-blur (4.4.1): - boost - DoubleConversion @@ -2797,6 +2799,8 @@ PODS: - React-perflogger (= 0.83.1) - React-utils (= 0.83.1) - SocketRocket + - RNCalendarEvents (2.2.0): + - React - RNCAsyncStorage (2.2.0): - boost - DoubleConversion @@ -3328,6 +3332,7 @@ DEPENDENCIES: - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - react-native-add-calendar-event (from `../node_modules/react-native-add-calendar-event`) - "react-native-blur (from `../node_modules/@react-native-community/blur`)" - "react-native-document-picker (from `../node_modules/@react-native-documents/picker`)" - "react-native-document-viewer (from `../node_modules/@react-native-documents/viewer`)" @@ -3368,6 +3373,7 @@ DEPENDENCIES: - ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`) - ReactCodegen (from `build/generated/ios/ReactCodegen`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - RNCalendarEvents (from `../node_modules/react-native-calendar-events`) - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) - RNFS (from `../node_modules/react-native-fs`) @@ -3486,6 +3492,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" React-microtasksnativemodule: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + react-native-add-calendar-event: + :path: "../node_modules/react-native-add-calendar-event" react-native-blur: :path: "../node_modules/@react-native-community/blur" react-native-document-picker: @@ -3566,6 +3574,8 @@ EXTERNAL SOURCES: :path: build/generated/ios/ReactCodegen ReactCommon: :path: "../node_modules/react-native/ReactCommon" + RNCalendarEvents: + :path: "../node_modules/react-native-calendar-events" RNCAsyncStorage: :path: "../node_modules/@react-native-async-storage/async-storage" RNDeviceInfo: @@ -3603,7 +3613,7 @@ SPEC CHECKSUMS: FBLazyVector: 309703e71d3f2f1ed7dc7889d58309c9d77a95a4 fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - hermes-engine: 4d40ce008f57c9348a66614a3167581aa861e379 + hermes-engine: 8c6be38f94b3bf8b864981980e64e55f08e467ec llama-rn: 796fa53f37f89e2c77cd6c462ad1172ee96d4c80 lottie-ios: a881093fab623c467d3bce374367755c272bdd59 lottie-react-native: 691b8363e8c591fb78a78254ff2517258891456b @@ -3644,6 +3654,7 @@ SPEC CHECKSUMS: React-logger: b8483fa08e0d62e430c76d864309d90576ca2f68 React-Mapbuffer: 7b72a669e94662359dad4f42b5af005eb24b4e83 React-microtasksnativemodule: cdc02da075f2857803ed63f24f5f72fc40e094c0 + react-native-add-calendar-event: 4eb42bdf84beb58de81f6d2ce1778a7632223dfe react-native-blur: 6af83e7e3c4c1446a188d9b2c493600fc4beb173 react-native-document-picker: dc2d83366e47e89e7c51e8a41eab99c1d54e941c react-native-document-viewer: 8c6ed07e7e27352743fa98e8dd6d288ad925b884 @@ -3684,6 +3695,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 0eb286cc274abb059ee601b862ebddac2e681d01 ReactCodegen: 3d48510bcef445f6403c0004047d4d9cbb915435 ReactCommon: ac934cb340aee91282ecd6f273a26d24d4c55cae + RNCalendarEvents: f90f73666b6bcbb3cc8a491ffbb5e48c0db3de37 RNCAsyncStorage: 29f0230e1a25f36c20b05f65e2eb8958d6526e82 RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388 RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 @@ -3701,6 +3713,6 @@ SPEC CHECKSUMS: whisper-rn: 7566faf9b7d78e39ab9fc634cb90fdee81177793 Yoga: 5456bb010373068fc92221140921b09d126b116e -PODFILE CHECKSUM: 31818a1f7d1207c486dba2e42df373cf65ace073 +PODFILE CHECKSUM: 30f084aba7ca9595d2440626582f30bcd67c6464 COCOAPODS: 1.16.2 diff --git a/package-lock.json b/package-lock.json index 9f919a7a..5200a3a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,8 @@ "patch-package": "^8.0.1", "react": "19.2.0", "react-native": "0.83.1", + "react-native-add-calendar-event": "^5.0.0", + "react-native-calendar-events": "^2.2.0", "react-native-device-info": "^15.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "^2.30.0", @@ -12335,6 +12337,30 @@ } } }, + "node_modules/react-native-add-calendar-event": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-native-add-calendar-event/-/react-native-add-calendar-event-5.0.0.tgz", + "integrity": "sha512-slkprraScOk7d8fm46a+P8FFKsQZRxABeRIYkVJjt4F7d9MYm52SzSYJ08BtOXK5vkn34gCq6VEOkcQ3i+wiAA==", + "deprecated": "Please use expo-calendar package instead. It provides the same features and some more.", + "license": "MIT", + "peerDependencies": { + "expo": ">=47.0.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/react-native-calendar-events": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-native-calendar-events/-/react-native-calendar-events-2.2.0.tgz", + "integrity": "sha512-tNUbhT6Ief0JM4OQzQAaz1ri0+MCcAoHptBcEXCz2g7q3A05pg62PR2Dio4F9t2fCAD7Y2+QggdY1ycAsF3Tsg==", + "license": "MIT", + "peerDependencies": { + "react-native": ">=0.60.0" + } + }, "node_modules/react-native-device-info": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/react-native-device-info/-/react-native-device-info-15.0.1.tgz", diff --git a/package.json b/package.json index c0557f25..2d9fe6b4 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "patch-package": "^8.0.1", "react": "19.2.0", "react-native": "0.83.1", + "react-native-add-calendar-event": "^5.0.0", + "react-native-calendar-events": "^2.2.0", "react-native-device-info": "^15.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "^2.30.0", diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts index 0a4a897a..0e840d9f 100644 --- a/src/services/tools/handlers.ts +++ b/src/services/tools/handlers.ts @@ -1,5 +1,7 @@ -import { Platform } from 'react-native'; +import { Platform, Linking } from 'react-native'; import DeviceInfo from 'react-native-device-info'; +import RNCalendarEvents from 'react-native-calendar-events'; +import * as RNAddCalendarEvent from 'react-native-add-calendar-event'; import { ToolCall, ToolResult } from './types'; import logger from '../../utils/logger'; @@ -45,6 +47,23 @@ async function dispatchTool(call: ToolCall): Promise { if (!url) throw new Error('Missing required parameter: url'); return handleReadUrl(url); } + case 'send_email': { + const to = requireString(call, 'to'); + if (!to) throw new Error('Missing required parameter: to'); + return handleSendEmail(to, call.arguments.subject, call.arguments.body); + } + case 'create_calendar_event': { + const title = requireString(call, 'title'); + const startDate = requireString(call, 'start_date'); + const endDate = requireString(call, 'end_date'); + if (!title) throw new Error('Missing required parameter: title'); + if (!startDate) throw new Error('Missing required parameter: start_date'); + if (!endDate) throw new Error('Missing required parameter: end_date'); + return handleCreateCalendarEvent(title, startDate, endDate, call.arguments.location, call.arguments.notes); + } + case 'read_calendar_events': { + return handleReadCalendarEvents(call.arguments.start_date, call.arguments.end_date); + } default: throw new Error(`Unknown tool: ${call.name}`); } @@ -390,3 +409,54 @@ function formatBytes(bytes: number): string { if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; return bytes < 1024 ** 3 ? `${(bytes / 1024 ** 2).toFixed(1)} MB` : `${(bytes / 1024 ** 3).toFixed(1)} GB`; } + +async function handleSendEmail(to: string, subject?: string, body?: string): Promise { + const parts: string[] = []; + if (subject) parts.push(`subject=${encodeURIComponent(subject)}`); + if (body) parts.push(`body=${encodeURIComponent(body)}`); + const url = `mailto:${encodeURIComponent(to)}${parts.length ? `?${parts.join('&')}` : ''}`; + await Linking.openURL(url); + return `Mail app opened with a draft to ${to}${subject ? ` (subject: "${subject}")` : ''}.`; +} + +async function handleCreateCalendarEvent( + title: string, + startDate: string, + endDate: string, + location?: string, + notes?: string, +): Promise { + if (!(RNAddCalendarEvent as any)?.presentEventCreatingDialog) { + throw new Error('Calendar package not available. Rebuild the app to use this tool.'); + } + const result = await RNAddCalendarEvent.presentEventCreatingDialog({ + title, + startDate: new Date(startDate).toISOString(), + endDate: new Date(endDate).toISOString(), + ...(location ? { location } : {}), + ...(notes ? { notes } : {}), + }); + if (result.action === 'CANCELED') { + return 'Calendar event creation was cancelled by the user.'; + } + return `Calendar event "${title}" saved from ${startDate} to ${endDate}${location ? ` at ${location}` : ''}.`; +} + +async function handleReadCalendarEvents(startDateStr?: string, endDateStr?: string): Promise { + if (!(RNCalendarEvents as any)?.requestPermissions) { + throw new Error('Calendar package not available. Rebuild the app to use this tool.'); + } + const status = await RNCalendarEvents.requestPermissions(true); + if (status !== 'authorized') throw new Error('Calendar permission denied'); + const startDt = startDateStr ? new Date(startDateStr) : new Date(); + const endDt = endDateStr ? new Date(endDateStr) : new Date(startDt.getTime() + 7 * 24 * 60 * 60 * 1000); + const events = await RNCalendarEvents.fetchAllEvents(startDt.toISOString(), endDt.toISOString()); + if (events.length === 0) { + return `No calendar events found between ${startDt.toDateString()} and ${endDt.toDateString()}.`; + } + return events.map(e => { + const s = new Date(e.startDate).toLocaleString(); + const en = e.endDate ? new Date(e.endDate).toLocaleString() : 'unknown'; + return `- ${e.title}\n Start: ${s}\n End: ${en}${e.location ? `\n Location: ${e.location}` : ''}${e.notes ? `\n Notes: ${e.notes}` : ''}`; + }).join('\n\n'); +} From 774405b160af8ec3f8af08f9be13dbbbeab1f46f Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 11 Jun 2026 17:21:11 +0530 Subject: [PATCH 07/28] feat: add ToolExtension plug-in seam wiring MCP tools into the generation loop - extensions.ts: ToolExtension interface and registerToolExtension / getToolExtensions API - registry.ts: resolves tools from both built-in registry and all registered extensions - generationToolLoop.ts: calls extensions on each loop iteration to include MCP tools Co-Authored-By: Dishit Karia --- src/services/generationToolLoop.ts | 9 +++- src/services/tools/extensions.ts | 1 + src/services/tools/registry.ts | 71 ++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/services/generationToolLoop.ts b/src/services/generationToolLoop.ts index 20ac96b5..ee1b9caf 100644 --- a/src/services/generationToolLoop.ts +++ b/src/services/generationToolLoop.ts @@ -343,7 +343,9 @@ function buildLiteRTToolCallHandler(ctx: ToolLoopContext, conversationId: string ctx.callbacks?.onToolCallStart?.(name, args as Record); const toolCall: ToolCall = { id: `native-tc-${Date.now()}`, name, arguments: args as Record }; if (ctx.projectId) (toolCall as any).context = { projectId: ctx.projectId }; - const result = await executeToolCall(toolCall); + const exts = getToolExtensions(); + const ext = exts.find(e => e.canHandle(name)); + const result = ext ? await ext.execute(toolCall) : await executeToolCall(toolCall); ctx.callbacks?.onToolCallComplete?.(name, result); const resultContent = result.error ? `Error: ${result.error}` : result.content; const toolCallMsg: Message = { id: `tc-${Date.now()}-${name}`, role: 'assistant', content: '', @@ -533,7 +535,10 @@ async function forceFinalTextResponse(ctx: ToolLoopContext, state: ToolLoopState */ export async function runToolLoop(ctx: ToolLoopContext): Promise { const chatStore = useChatStore.getState(); - const toolSchemas = getToolsAsOpenAISchema(ctx.enabledToolIds); + const toolSchemas = [ + ...getToolsAsOpenAISchema(ctx.enabledToolIds), + ...getToolExtensions().flatMap(e => e.getOpenAISchemas?.() ?? []), + ]; const loopMessages = [...ctx.messages]; let totalToolCalls = 0; const state: ToolLoopState = { firstTokenFired: false, thinkingDoneFired: false, streamedContent: '', reasoningContent: '' }; diff --git a/src/services/tools/extensions.ts b/src/services/tools/extensions.ts index 12e20bc1..24f08711 100644 --- a/src/services/tools/extensions.ts +++ b/src/services/tools/extensions.ts @@ -3,6 +3,7 @@ import type { ToolCall, ToolResult } from './types'; export interface ToolExtension { id: string; getSystemPromptHint(): string; + getOpenAISchemas?(): any[]; parseToolCalls(text: string): ToolCall[]; stripFromVisibleText(text: string): string; canHandle(toolName: string): boolean; diff --git a/src/services/tools/registry.ts b/src/services/tools/registry.ts index d5c11dd3..a2485741 100644 --- a/src/services/tools/registry.ts +++ b/src/services/tools/registry.ts @@ -86,6 +86,77 @@ export const AVAILABLE_TOOLS: ToolDefinition[] = [ }, }, }, + { + id: 'send_email', + name: 'send_email', + displayName: 'Send Email', + description: 'Open the default mail app to send an email', + icon: 'mail', + parameters: { + to: { + type: 'string', + description: 'Recipient email address', + required: true, + }, + subject: { + type: 'string', + description: 'Email subject', + }, + body: { + type: 'string', + description: 'Email body', + }, + }, + }, + { + id: 'create_calendar_event', + name: 'create_calendar_event', + displayName: 'Create Calendar Event', + description: 'Create an event in the device calendar', + icon: 'calendar', + parameters: { + title: { + type: 'string', + description: 'Event title', + required: true, + }, + start_date: { + type: 'string', + description: 'Start date/time in ISO 8601 format, e.g. 2025-06-01T10:00:00', + required: true, + }, + end_date: { + type: 'string', + description: 'End date/time in ISO 8601 format', + required: true, + }, + location: { + type: 'string', + description: 'Event location', + }, + notes: { + type: 'string', + description: 'Additional notes for the event', + }, + }, + }, + { + id: 'read_calendar_events', + name: 'read_calendar_events', + displayName: 'Read Calendar Events', + description: 'Read upcoming events from the device calendar', + icon: 'calendar', + parameters: { + start_date: { + type: 'string', + description: 'Start of date range in ISO 8601 format. Defaults to today.', + }, + end_date: { + type: 'string', + description: 'End of date range in ISO 8601 format. Defaults to 7 days from start.', + }, + }, + }, ]; export function getToolsAsOpenAISchema(enabledToolIds: string[]) { From 00b2e11b9e29dc43b48c40a3cda3c045436d9298 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 11 Jun 2026 17:21:16 +0530 Subject: [PATCH 08/28] fix: show correct tool count and independent amber warning per badge - Pass chat.enabledTools.length to enabledToolCount (was totalToolCount which included MCP tools) - Tools badge turns amber independently when built-in enabled tools >= 3 - MCP badge turns amber independently when MCP tools >= 3 - Neither badge's warning depends on the other's count Co-Authored-By: Dishit Karia --- src/components/ChatInput/Popovers.tsx | 15 ++++++++++----- src/screens/ChatScreen/ChatMessageArea.tsx | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/ChatInput/Popovers.tsx b/src/components/ChatInput/Popovers.tsx index 76842fdd..a248e91d 100644 --- a/src/components/ChatInput/Popovers.tsx +++ b/src/components/ChatInput/Popovers.tsx @@ -110,9 +110,14 @@ export const QuickSettingsPopover: React.FC = ({ const imgBadge = getImageModeBadge(imageMode, colors); const tools = getToolsStyle(supportsToolCalling, enabledToolCount, colors); - const showToolWarning = supportsToolCalling && enabledToolCount > 3 && !toolCountHintDismissed; - const toolIconColor = showToolWarning ? TOOL_WARNING_COLOR : tools.iconColor; - const toolBadgeBg = showToolWarning ? TOOL_WARNING_COLOR : tools.badgeBg; + + // Tools and MCP warnings are independent — each turns amber at 3+ + const showToolsWarning = supportsToolCalling && enabledToolCount >= 3 && !toolCountHintDismissed; + const showMcpWarning = mcpToolCount >= 3; + + const toolIconColor = showToolsWarning ? TOOL_WARNING_COLOR : tools.iconColor; + const toolBadgeBg = showToolsWarning ? TOOL_WARNING_COLOR : tools.badgeBg; + const mcpBadgeBg = showMcpWarning ? TOOL_WARNING_COLOR : (mcpToolCount > 0 ? colors.primary : colors.textMuted); return ( @@ -183,9 +188,9 @@ export const QuickSettingsPopover: React.FC = ({ onMcpPress?.(); }} > - 0 ? colors.primary : colors.textMuted} /> + MCP - 0 ? colors.primary : colors.textMuted }]}> + {mcpToolCount} diff --git a/src/screens/ChatScreen/ChatMessageArea.tsx b/src/screens/ChatScreen/ChatMessageArea.tsx index 854de725..7cafe867 100644 --- a/src/screens/ChatScreen/ChatMessageArea.tsx +++ b/src/screens/ChatScreen/ChatMessageArea.tsx @@ -169,7 +169,7 @@ export const ChatMessageArea: React.FC = ({ imageOnly: chat.imageModelLoaded && !chat.hasTextModel, })} onToolsPress={() => chat.setShowToolPicker(true)} - enabledToolCount={totalToolCount} + enabledToolCount={chat.enabledTools.length} showSettingsDot={showSettingsDot} mcpToolCount={extToolCount} onMcpPress={handleMcpPress} From e0b0d5bb7811c197f7d71670f2b347efe7e467b2 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 11 Jun 2026 17:21:20 +0530 Subject: [PATCH 09/28] fix: replace hardcoded spacing with design tokens in ToolPickerSheet - paddingHorizontal: SPACING.lg (was hardcoded) - paddingBottom: SPACING.xl (was hardcoded) Co-Authored-By: Dishit Karia --- src/components/ToolPickerSheet.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/ToolPickerSheet.tsx b/src/components/ToolPickerSheet.tsx index c32a762f..269d8d7f 100644 --- a/src/components/ToolPickerSheet.tsx +++ b/src/components/ToolPickerSheet.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, Switch, TouchableOpacity } from 'react-native'; +import { View, Text, Switch, TouchableOpacity, ScrollView } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; import { AppSheet } from './AppSheet'; import { useTheme, useThemedStyles } from '../theme'; @@ -36,7 +36,7 @@ export const ToolPickerSheet: React.FC = ({ enableDynamicSizing title="Tools" > - + {showHint && ( @@ -78,15 +78,18 @@ export const ToolPickerSheet: React.FC = ({ Enabling more tools can confuse the model and increases latency on first response. - + ); }; const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ container: { - paddingHorizontal: 16, - paddingBottom: 24, + maxHeight: 420, + }, + contentContainer: { + paddingHorizontal: SPACING.lg, + paddingBottom: SPACING.xl, }, toolRow: { flexDirection: 'row' as const, From 98100b775ce9833427b7193f469aa60225109c5c Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 11 Jun 2026 17:21:27 +0530 Subject: [PATCH 10/28] feat: improve context-full dialog with prominent message and action buttons - CustomAlert: add prominentMessage prop rendering message in body typography for readability - showAlert accepts options.prominentMessage to propagate the flag - Context-full dialog: remove OK button, add Settings and New chat actions - Dialog message reordered for clarity: consequence first, then context budget detail Co-Authored-By: Dishit Karia --- src/components/CustomAlert.tsx | 12 +++- src/screens/ChatScreen/index.tsx | 1 + .../ChatScreen/useChatGenerationActions.ts | 57 ++++++++++++++++++- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/components/CustomAlert.tsx b/src/components/CustomAlert.tsx index 1074ae8e..3f98f5cb 100644 --- a/src/components/CustomAlert.tsx +++ b/src/components/CustomAlert.tsx @@ -24,6 +24,7 @@ export interface CustomAlertProps { onClose?: () => void; loading?: boolean; closeLabel?: string; + prominentMessage?: boolean; } export const CustomAlert: React.FC = ({ @@ -34,6 +35,7 @@ export const CustomAlert: React.FC = ({ onClose, loading = false, closeLabel = 'Done', + prominentMessage = false, }) => { const { colors } = useTheme(); const styles = useThemedStyles(createStyles); @@ -55,7 +57,7 @@ export const CustomAlert: React.FC = ({ {loading ? ( ) : null} - {message ? {message} : null} + {message ? {message} : null} {buttons.map((button, index) => ( ({ visible: true, title, message, buttons, loading: false, + prominentMessage: options?.prominentMessage, }); // Helper function to show loading alert (returns state to set) @@ -147,6 +152,11 @@ const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ lineHeight: 20, marginBottom: SPACING.lg, }, + messageProminent: { + ...TYPOGRAPHY.body, + color: colors.text, + lineHeight: 22, + }, buttonContainer: { flexDirection: 'row' as const, marginTop: SPACING.sm, diff --git a/src/screens/ChatScreen/index.tsx b/src/screens/ChatScreen/index.tsx index af48635c..b7d3d22c 100644 --- a/src/screens/ChatScreen/index.tsx +++ b/src/screens/ChatScreen/index.tsx @@ -132,6 +132,7 @@ export const ChatScreen: React.FC = () => { title={chat.alertState.title} message={chat.alertState.message} buttons={chat.alertState.buttons} + prominentMessage={chat.alertState.prominentMessage} onClose={() => chat.setAlertState(hideAlert())} /> ); diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index d1917ee8..954cd2b2 100644 --- a/src/screens/ChatScreen/useChatGenerationActions.ts +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -302,7 +302,33 @@ export async function startGenerationFn(deps: GenerationDeps, call: StartGenerat } catch (error: any) { const msg = error?.message || error?.toString?.() || 'Failed to generate response'; logger.error('[ChatGen] Generation failed:', msg, error); - deps.setAlertState(showAlert('Generation Error', msg)); + const isContextOverflow = msg.includes('too long') || msg.includes('Exceeding the maximum number of tokens') || msg.includes('Input token ids'); + if (isContextOverflow) { + deps.setAlertState(showAlert( + 'Context window full', + 'The conversation is too long for this model\'s context window.\n\nIncrease the context limit in Settings, reduce the number of enabled tools, or start a new chat.', + [ + { + text: 'Settings', + onPress: () => { deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); deps.setShowSettingsPanel?.(true); }, + }, + { + text: 'New chat', + onPress: () => { + deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); + const modelId = deps.activeModelInfo?.modelId; + if (modelId) { + const newId = deps.createConversation(modelId); + deps.setActiveConversation(newId); + } + }, + }, + ], + { prominentMessage: true }, + )); + } else { + deps.setAlertState(showAlert('Generation Error', msg)); + } deps.generatingForConversationRef.current = null; return; } @@ -395,7 +421,34 @@ export async function regenerateResponseFn(deps: GenerationDeps, call: Regenerat try { await generateWithCompactionRetry({ id: targetConversationId, prompt: systemPrompt, messages: [...prefix, ...filtered] }, activeTools, conversation?.projectId); } catch (error: any) { - deps.setAlertState(showAlert('Generation Error', error.message || 'Failed to generate response')); + const msg = error?.message || 'Failed to generate response'; + const isContextOverflow = msg.includes('too long') || msg.includes('Exceeding the maximum number of tokens') || msg.includes('Input token ids'); + if (isContextOverflow) { + deps.setAlertState(showAlert( + 'Context window full', + 'The conversation is too long for this model\'s context window.\n\nIncrease the context limit in Settings, reduce the number of enabled tools, or start a new chat.', + [ + { + text: 'Settings', + onPress: () => { deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); deps.setShowSettingsPanel?.(true); }, + }, + { + text: 'New chat', + onPress: () => { + deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); + const modelId = deps.activeModelInfo?.modelId; + if (modelId) { + const newId = deps.createConversation(modelId); + deps.setActiveConversation(newId); + } + }, + }, + ], + { prominentMessage: true }, + )); + } else { + deps.setAlertState(showAlert('Generation Error', msg)); + } } deps.generatingForConversationRef.current = null; } From 00ae3b25a42c6d3b0861cbdf03df3e46c422df60 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 11 Jun 2026 17:21:32 +0530 Subject: [PATCH 11/28] chore: bump pro submodule ref to include MCP redesign and new screens Includes P1-P5 from the pro package: - Type safety and selector naming fixes - MCP server list card redesign with avatar, toggle and stats row - McpToolsScreen full-screen tool picker - McpServerModal conditional auth dropdown redesign - McpGuideScreen with model recommendations and context tips Co-Authored-By: Dishit Karia --- pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pro b/pro index 81ecf7e4..20767551 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 81ecf7e4da58ad65eb816e53b102fe9c0dca5ae8 +Subproject commit 20767551eb73b8cdf70fdbef41ea5c7b799eb5f2 From f91aaa8bd52b5c2052ec01bd009b3108c2004ecf Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 11 Jun 2026 17:44:50 +0530 Subject: [PATCH 12/28] fix: resolve pre-push lint errors in CustomAlert, handlers and ChatScreen - showAlert: drop 4th options param (max-params rule); caller spreads prominentMessage onto result - handleCreateCalendarEvent: collapse 5 params into single event object (max-params rule) - ChatScreen useEffect: add goTo to dependency array (react-hooks/exhaustive-deps warning) Co-Authored-By: Dishit Karia --- src/components/CustomAlert.tsx | 2 - src/screens/ChatScreen/index.tsx | 2 +- .../ChatScreen/useChatGenerationActions.ts | 88 ++++++++++--------- src/services/tools/handlers.ts | 15 ++-- 4 files changed, 56 insertions(+), 51 deletions(-) diff --git a/src/components/CustomAlert.tsx b/src/components/CustomAlert.tsx index 3f98f5cb..4d637d0c 100644 --- a/src/components/CustomAlert.tsx +++ b/src/components/CustomAlert.tsx @@ -109,14 +109,12 @@ export const showAlert = ( title: string, message?: string, buttons?: AlertButton[], - options?: { prominentMessage?: boolean }, ): AlertState => ({ visible: true, title, message, buttons, loading: false, - prominentMessage: options?.prominentMessage, }); // Helper function to show loading alert (returns state to set) diff --git a/src/screens/ChatScreen/index.tsx b/src/screens/ChatScreen/index.tsx index b7d3d22c..4983632e 100644 --- a/src/screens/ChatScreen/index.tsx +++ b/src/screens/ChatScreen/index.tsx @@ -72,7 +72,7 @@ export const ChatScreen: React.FC = () => { const task = InteractionManager.runAfterInteractions(() => goTo(pending)); return () => task.cancel(); } - }, []); + }, [goTo]); const chainingRef = useRef(false); // When the spotlight tour stops after step 3, fire the chained step 12 useEffect(() => { diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index 954cd2b2..6472e006 100644 --- a/src/screens/ChatScreen/useChatGenerationActions.ts +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -304,28 +304,30 @@ export async function startGenerationFn(deps: GenerationDeps, call: StartGenerat logger.error('[ChatGen] Generation failed:', msg, error); const isContextOverflow = msg.includes('too long') || msg.includes('Exceeding the maximum number of tokens') || msg.includes('Input token ids'); if (isContextOverflow) { - deps.setAlertState(showAlert( - 'Context window full', - 'The conversation is too long for this model\'s context window.\n\nIncrease the context limit in Settings, reduce the number of enabled tools, or start a new chat.', - [ - { - text: 'Settings', - onPress: () => { deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); deps.setShowSettingsPanel?.(true); }, - }, - { - text: 'New chat', - onPress: () => { - deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); - const modelId = deps.activeModelInfo?.modelId; - if (modelId) { - const newId = deps.createConversation(modelId); - deps.setActiveConversation(newId); - } + deps.setAlertState({ + ...showAlert( + 'Context window full', + 'The conversation is too long for this model\'s context window.\n\nIncrease the context limit in Settings, reduce the number of enabled tools, or start a new chat.', + [ + { + text: 'Settings', + onPress: () => { deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); deps.setShowSettingsPanel?.(true); }, }, - }, - ], - { prominentMessage: true }, - )); + { + text: 'New chat', + onPress: () => { + deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); + const modelId = deps.activeModelInfo?.modelId; + if (modelId) { + const newId = deps.createConversation(modelId); + deps.setActiveConversation(newId); + } + }, + }, + ], + ), + prominentMessage: true, + }); } else { deps.setAlertState(showAlert('Generation Error', msg)); } @@ -424,28 +426,30 @@ export async function regenerateResponseFn(deps: GenerationDeps, call: Regenerat const msg = error?.message || 'Failed to generate response'; const isContextOverflow = msg.includes('too long') || msg.includes('Exceeding the maximum number of tokens') || msg.includes('Input token ids'); if (isContextOverflow) { - deps.setAlertState(showAlert( - 'Context window full', - 'The conversation is too long for this model\'s context window.\n\nIncrease the context limit in Settings, reduce the number of enabled tools, or start a new chat.', - [ - { - text: 'Settings', - onPress: () => { deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); deps.setShowSettingsPanel?.(true); }, - }, - { - text: 'New chat', - onPress: () => { - deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); - const modelId = deps.activeModelInfo?.modelId; - if (modelId) { - const newId = deps.createConversation(modelId); - deps.setActiveConversation(newId); - } + deps.setAlertState({ + ...showAlert( + 'Context window full', + 'The conversation is too long for this model\'s context window.\n\nIncrease the context limit in Settings, reduce the number of enabled tools, or start a new chat.', + [ + { + text: 'Settings', + onPress: () => { deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); deps.setShowSettingsPanel?.(true); }, + }, + { + text: 'New chat', + onPress: () => { + deps.setAlertState({ visible: false, title: '', message: '', buttons: [] }); + const modelId = deps.activeModelInfo?.modelId; + if (modelId) { + const newId = deps.createConversation(modelId); + deps.setActiveConversation(newId); + } + }, }, - }, - ], - { prominentMessage: true }, - )); + ], + ), + prominentMessage: true, + }); } else { deps.setAlertState(showAlert('Generation Error', msg)); } diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts index 0e840d9f..c9ab0091 100644 --- a/src/services/tools/handlers.ts +++ b/src/services/tools/handlers.ts @@ -59,7 +59,13 @@ async function dispatchTool(call: ToolCall): Promise { if (!title) throw new Error('Missing required parameter: title'); if (!startDate) throw new Error('Missing required parameter: start_date'); if (!endDate) throw new Error('Missing required parameter: end_date'); - return handleCreateCalendarEvent(title, startDate, endDate, call.arguments.location, call.arguments.notes); + return handleCreateCalendarEvent({ + title, + startDate, + endDate, + location: call.arguments.location as string | undefined, + notes: call.arguments.notes as string | undefined, + }); } case 'read_calendar_events': { return handleReadCalendarEvents(call.arguments.start_date, call.arguments.end_date); @@ -420,12 +426,9 @@ async function handleSendEmail(to: string, subject?: string, body?: string): Pro } async function handleCreateCalendarEvent( - title: string, - startDate: string, - endDate: string, - location?: string, - notes?: string, + event: { title: string; startDate: string; endDate: string; location?: string; notes?: string }, ): Promise { + const { title, startDate, endDate, location, notes } = event; if (!(RNAddCalendarEvent as any)?.presentEventCreatingDialog) { throw new Error('Calendar package not available. Rebuild the app to use this tool.'); } From 505230b992a7c79fb0adffa60f11b97c81751323 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 11 Jun 2026 17:51:49 +0530 Subject: [PATCH 13/28] chore: remove stale debugLogsStore test referencing removed API loadFromStorage and loaded were removed from DebugLogsState. Co-Authored-By: Dishit Karia --- __tests__/unit/stores/debugLogsStore.test.ts | 68 -------------------- 1 file changed, 68 deletions(-) delete mode 100644 __tests__/unit/stores/debugLogsStore.test.ts diff --git a/__tests__/unit/stores/debugLogsStore.test.ts b/__tests__/unit/stores/debugLogsStore.test.ts deleted file mode 100644 index b0660faf..00000000 --- a/__tests__/unit/stores/debugLogsStore.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -jest.mock('@react-native-async-storage/async-storage', () => ({ - getItem: jest.fn(() => Promise.resolve(null)), - setItem: jest.fn(() => Promise.resolve()), - removeItem: jest.fn(() => Promise.resolve()), -})); - -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useDebugLogsStore } from '../../../src/stores/debugLogsStore'; - -const mockedGetItem = AsyncStorage.getItem as jest.Mock; -const mockedRemoveItem = AsyncStorage.removeItem as jest.Mock; - -beforeEach(() => { - jest.clearAllMocks(); - useDebugLogsStore.setState({ logs: [], loaded: false } as any); -}); - -describe('debugLogsStore', () => { - describe('loadFromStorage', () => { - it('loads logs from AsyncStorage when raw data exists', async () => { - const stored = [{ timestamp: 1000, level: 'log', message: 'hello' }]; - mockedGetItem.mockResolvedValueOnce(JSON.stringify(stored)); - - await useDebugLogsStore.getState().loadFromStorage(); - - expect(useDebugLogsStore.getState().logs).toHaveLength(1); - expect(useDebugLogsStore.getState().logs[0].message).toBe('hello'); - expect(useDebugLogsStore.getState().loaded).toBe(true); - }); - - it('sets loaded=true and keeps empty logs when AsyncStorage has no data', async () => { - mockedGetItem.mockResolvedValueOnce(null); - - await useDebugLogsStore.getState().loadFromStorage(); - - expect(useDebugLogsStore.getState().logs).toHaveLength(0); - expect(useDebugLogsStore.getState().loaded).toBe(true); - }); - - it('skips the read when already loaded', async () => { - useDebugLogsStore.setState({ loaded: true } as any); - - await useDebugLogsStore.getState().loadFromStorage(); - - expect(mockedGetItem).not.toHaveBeenCalled(); - }); - - it('sets loaded=true and keeps empty logs when AsyncStorage throws', async () => { - mockedGetItem.mockRejectedValueOnce(new Error('storage error')); - - await useDebugLogsStore.getState().loadFromStorage(); - - expect(useDebugLogsStore.getState().loaded).toBe(true); - expect(useDebugLogsStore.getState().logs).toHaveLength(0); - }); - }); - - describe('clearLogs', () => { - it('empties the logs array and calls AsyncStorage.removeItem', () => { - useDebugLogsStore.setState({ logs: [{ timestamp: 1, level: 'log', message: 'x' }] } as any); - - useDebugLogsStore.getState().clearLogs(); - - expect(useDebugLogsStore.getState().logs).toHaveLength(0); - expect(mockedRemoveItem).toHaveBeenCalled(); - }); - }); -}); From d273d7f8ccbb6f73cd11e694c003f075e0cc7bb4 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 11 Jun 2026 17:55:54 +0530 Subject: [PATCH 14/28] test: update registry count and fix conversation ID in extension loop tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registry.test.ts: expect 9 tools (was 6) — add send_email, create_calendar_event, read_calendar_events - toolExtensionLoop.test.ts: createConversation takes a modelId and returns the UUID; capture the returned ID instead of passing 'test-conv-1' as the conversationId — addMessage was silently no-oping because no conversation matched the hardcoded string Co-Authored-By: Dishit Karia --- __tests__/integration/generation/toolExtensionLoop.test.ts | 4 ++-- __tests__/unit/services/tools/registry.test.ts | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/__tests__/integration/generation/toolExtensionLoop.test.ts b/__tests__/integration/generation/toolExtensionLoop.test.ts index 4d8166cf..35ffb830 100644 --- a/__tests__/integration/generation/toolExtensionLoop.test.ts +++ b/__tests__/integration/generation/toolExtensionLoop.test.ts @@ -71,8 +71,8 @@ function makeFakeExtension(executorMock: jest.Mock): ToolExtension { } function makeCtx(overrides: Partial = {}): ToolLoopContext { - const conversationId = 'test-conv-1'; - useChatStore.getState().createConversation(conversationId); + // createConversation takes a modelId and returns the generated conversation UUID + const conversationId = useChatStore.getState().createConversation('test-model'); return { conversationId, messages: [ diff --git a/__tests__/unit/services/tools/registry.test.ts b/__tests__/unit/services/tools/registry.test.ts index 94991672..0ce727ee 100644 --- a/__tests__/unit/services/tools/registry.test.ts +++ b/__tests__/unit/services/tools/registry.test.ts @@ -16,8 +16,8 @@ describe('Tool Registry', () => { // AVAILABLE_TOOLS // ======================================================================== describe('AVAILABLE_TOOLS', () => { - it('has exactly 6 tools with correct IDs', () => { - expect(AVAILABLE_TOOLS).toHaveLength(6); + it('has exactly 9 tools with correct IDs', () => { + expect(AVAILABLE_TOOLS).toHaveLength(9); const ids = AVAILABLE_TOOLS.map(t => t.id); expect(ids).toEqual([ @@ -27,6 +27,9 @@ describe('Tool Registry', () => { 'get_device_info', 'search_knowledge_base', 'read_url', + 'send_email', + 'create_calendar_event', + 'read_calendar_events', ]); }); From dd35168389d4dfd2dfd9ac10dede3bd1304fafb0 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 11 Jun 2026 17:56:48 +0530 Subject: [PATCH 15/28] fix: rename inner mock destructures to avoid no-shadow lint error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useChatStore and useAppStore inside jest.mock() factory shadowed the top-level imports on lines 19–20. Co-Authored-By: Dishit Karia --- .../integration/generation/toolExtensionLoop.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/__tests__/integration/generation/toolExtensionLoop.test.ts b/__tests__/integration/generation/toolExtensionLoop.test.ts index 35ffb830..5615e63d 100644 --- a/__tests__/integration/generation/toolExtensionLoop.test.ts +++ b/__tests__/integration/generation/toolExtensionLoop.test.ts @@ -38,11 +38,11 @@ jest.mock('../../../src/services/tools', () => ({ // stay on the local path. Requiring the submodules directly avoids loading the full // stores index (auth/whisper/project), which fails to initialise in this test env. jest.mock('../../../src/stores', () => { - const { useChatStore } = jest.requireActual('../../../src/stores/chatStore'); - const { useAppStore } = jest.requireActual('../../../src/stores/appStore'); + const { useChatStore: realChatStore } = jest.requireActual('../../../src/stores/chatStore'); + const { useAppStore: realAppStore } = jest.requireActual('../../../src/stores/appStore'); return { - useChatStore, - useAppStore, + useChatStore: realChatStore, + useAppStore: realAppStore, useRemoteServerStore: { getState: () => ({ activeServerId: null, activeRemoteTextModelId: null }), }, From c212f026c33fd046ad572024a4ebce8b2a9e2743 Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 12 Jun 2026 12:52:48 +0530 Subject: [PATCH 16/28] fix: address Gemini review comments and remove debug logs - Remove duplicate getSettingsSections render in SettingsScreen - Add dedup guards to registerScreen and registerSettingsSection - Validate dates before toISOString in calendar tool handlers - Wrap Linking.openURL in try/catch in handleSendEmail - Strip all logger.log/warn from generationToolLoop, useChatGenerationActions, loadProFeatures - Replace proLicenseService with RevenueCat-based implementation - Update proLicenseService tests to match new API Co-Authored-By: Dishit Karia --- .../unit/services/proLicenseService.test.ts | 89 ++++++++--------- src/bootstrap/loadProFeatures.ts | 15 +-- src/components/settings/sectionRegistry.ts | 4 +- src/navigation/screenRegistry.ts | 4 +- .../ChatScreen/useChatGenerationActions.ts | 14 +-- src/screens/SettingsScreen.tsx | 3 - src/services/generationToolLoop.ts | 44 +-------- src/services/proLicenseService.ts | 97 ++++++++++++++----- src/services/tools/handlers.ts | 21 +++- 9 files changed, 147 insertions(+), 144 deletions(-) diff --git a/__tests__/unit/services/proLicenseService.test.ts b/__tests__/unit/services/proLicenseService.test.ts index f8acc398..a848e8be 100644 --- a/__tests__/unit/services/proLicenseService.test.ts +++ b/__tests__/unit/services/proLicenseService.test.ts @@ -1,70 +1,67 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import { - refreshProStatus, - isPro, - onProStatusChange, - _resetForTesting, + readProFromKeychain, + checkProStatus, } from '../../../src/services/proLicenseService'; -const mockedGetItem = AsyncStorage.getItem as jest.Mock; +jest.mock('react-native-purchases', () => ({ + default: { setLogLevel: jest.fn(), configure: jest.fn(), getCustomerInfo: jest.fn() }, + LOG_LEVEL: { DEBUG: 'debug', ERROR: 'error' }, +})); + +jest.mock('react-native-purchases-ui', () => ({ + default: { presentPaywall: jest.fn() }, + PAYWALL_RESULT: { PURCHASED: 'PURCHASED', RESTORED: 'RESTORED', NOT_PRESENTED: 'NOT_PRESENTED', ERROR: 'ERROR', CANCELLED: 'CANCELLED' }, +})); + +const mockGetGenericPassword = jest.fn(); +const mockSetGenericPassword = jest.fn(); +const mockResetGenericPassword = jest.fn(); + +jest.mock('react-native-keychain', () => ({ + getGenericPassword: (...args: any[]) => mockGetGenericPassword(...args), + setGenericPassword: (...args: any[]) => mockSetGenericPassword(...args), + resetGenericPassword: (...args: any[]) => mockResetGenericPassword(...args), + ACCESSIBLE: { AFTER_FIRST_UNLOCK: 'AfterFirstUnlock' }, +})); describe('proLicenseService', () => { beforeEach(() => { - _resetForTesting(); jest.clearAllMocks(); }); - describe('isPro()', () => { - it('returns false before refreshProStatus has been called', () => { - expect(isPro()).toBe(false); + describe('readProFromKeychain()', () => { + it('returns false when no keychain entry exists', async () => { + mockGetGenericPassword.mockResolvedValueOnce(false); + expect(await readProFromKeychain()).toBe(false); }); - it('returns false when no receipt is stored', async () => { - mockedGetItem.mockResolvedValueOnce(null); - await refreshProStatus(); - expect(isPro()).toBe(false); + it('returns false when keychain entry has isPro=false', async () => { + mockGetGenericPassword.mockResolvedValueOnce({ password: JSON.stringify({ isPro: false, verifiedAt: Date.now() }) }); + expect(await readProFromKeychain()).toBe(false); }); - it('returns false when stored receipt is too short (<= 10 chars)', async () => { - mockedGetItem.mockResolvedValueOnce('short'); - await refreshProStatus(); - expect(isPro()).toBe(false); + it('returns true when keychain entry has isPro=true', async () => { + mockGetGenericPassword.mockResolvedValueOnce({ password: JSON.stringify({ isPro: true, verifiedAt: Date.now() }) }); + expect(await readProFromKeychain()).toBe(true); }); - it('returns true when a valid receipt is stored', async () => { - mockedGetItem.mockResolvedValueOnce('valid-receipt-longer-than-ten-chars'); - await refreshProStatus(); - expect(isPro()).toBe(true); + it('returns false when keychain entry is malformed', async () => { + mockGetGenericPassword.mockResolvedValueOnce({ password: 'not-json' }); + expect(await readProFromKeychain()).toBe(false); }); }); - describe('refreshProStatus()', () => { - it('returns the resolved boolean', async () => { - mockedGetItem.mockResolvedValueOnce('valid-receipt-longer-than-ten-chars'); - const result = await refreshProStatus(); + describe('checkProStatus()', () => { + it('returns the cached keychain value immediately', async () => { + mockGetGenericPassword.mockResolvedValueOnce({ password: JSON.stringify({ isPro: true, verifiedAt: Date.now() }) }); + const result = await checkProStatus(); expect(result).toBe(true); }); - it('notifies all listeners after refresh', async () => { - mockedGetItem.mockResolvedValue('valid-receipt-longer-than-ten-chars'); - const cb1 = jest.fn(); - const cb2 = jest.fn(); - onProStatusChange(cb1); - onProStatusChange(cb2); - await refreshProStatus(); - expect(cb1).toHaveBeenCalledTimes(1); - expect(cb2).toHaveBeenCalledTimes(1); - }); - }); - - describe('onProStatusChange()', () => { - it('returns an unsubscribe function that stops future notifications', async () => { - mockedGetItem.mockResolvedValue(null); - const cb = jest.fn(); - const unsub = onProStatusChange(cb); - unsub(); - await refreshProStatus(); - expect(cb).not.toHaveBeenCalled(); + it('returns false when keychain is empty', async () => { + mockGetGenericPassword.mockResolvedValueOnce(false); + const result = await checkProStatus(); + expect(result).toBe(false); }); }); }); diff --git a/src/bootstrap/loadProFeatures.ts b/src/bootstrap/loadProFeatures.ts index f0e1e567..1dd84914 100644 --- a/src/bootstrap/loadProFeatures.ts +++ b/src/bootstrap/loadProFeatures.ts @@ -1,4 +1,3 @@ -import { refreshProStatus, onProStatusChange } from '../services/proLicenseService'; import { registerToolExtension } from '../services/tools/extensions'; import { registerScreen } from '../navigation/screenRegistry'; import { registerSettingsSection } from '../components/settings/sectionRegistry'; @@ -8,24 +7,14 @@ export async function loadProFeatures(): Promise { try { // eslint-disable-next-line @typescript-eslint/no-var-requires pro = require('@offgrid/pro'); - } catch (err) { - console.warn('[loadProFeatures] require(@offgrid/pro) threw:', err); + } catch { return; // free / contributor build: package not installed } if (!pro) { - console.warn('[loadProFeatures] @offgrid/pro resolved to null (stub build)'); return; // proStub.js returns null — free build via metro extraNodeModules } // Run synchronously before any await so screens are registered before the // navigator renders (App.tsx doesn't await loadProFeatures). - const activate = () => { - pro.activate({ registerToolExtension, registerScreen, registerSettingsSection }); - }; - activate(); - console.log('[loadProFeatures] pro activated, registered screens'); - - // Async: refresh receipt so pro-gated UI can react. - await refreshProStatus(); - onProStatusChange(activate); + pro.activate({ registerToolExtension, registerScreen, registerSettingsSection }); } diff --git a/src/components/settings/sectionRegistry.ts b/src/components/settings/sectionRegistry.ts index 8d60d820..0b0b2cbf 100644 --- a/src/components/settings/sectionRegistry.ts +++ b/src/components/settings/sectionRegistry.ts @@ -3,7 +3,9 @@ import type { ComponentType } from 'react'; const sections: ComponentType[] = []; export function registerSettingsSection(component: ComponentType): void { - sections.push(component); + if (!sections.includes(component)) { + sections.push(component); + } } export function getSettingsSections(): ComponentType[] { diff --git a/src/navigation/screenRegistry.ts b/src/navigation/screenRegistry.ts index c9f15986..89805e89 100644 --- a/src/navigation/screenRegistry.ts +++ b/src/navigation/screenRegistry.ts @@ -8,7 +8,9 @@ export interface RegisteredScreen { const screens: RegisteredScreen[] = []; export function registerScreen(screen: RegisteredScreen): void { - screens.push(screen); + if (!screens.some(s => s.name === screen.name)) { + screens.push(screen); + } } export function getRegisteredScreens(): RegisteredScreen[] { diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index 6472e006..77171e8e 100644 --- a/src/screens/ChatScreen/useChatGenerationActions.ts +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -123,7 +123,6 @@ export async function shouldRouteToImageGenerationFn( } return intent === 'image'; } catch (error) { - logger.warn('[ChatScreen] Intent classification failed:', error); deps.setIsClassifying(false); deps.setAppImageGenerationStatus(null); deps.setAppIsGeneratingImage(false); @@ -169,11 +168,10 @@ async function prepareContext(setDebugInfo: SetState, systemPrompt: string, try { const contextDebug = await llmService.getContextDebugInfo(messages); setDebugInfo({ systemPrompt, ...contextDebug }); - logger.log(`[ChatGen] Context prepared: ${contextDebug.contextUsagePercent}% used, ${contextDebug.truncatedCount} truncated`); if (contextDebug.truncatedCount > 0 || contextDebug.contextUsagePercent > 70) { await llmService.clearKVCache(false).catch(() => { }); } - } catch (e) { logger.log('Debug info error:', e); } + } catch { /* ignore */ } } /** Run generation; if context is full, compact old messages and retry once. */ async function generateWithCompactionRetry( @@ -187,12 +185,10 @@ async function generateWithCompactionRetry( : generationService.generateResponse(opts.id, msgs); try { await gen(opts.messages); } catch (error: any) { if (!contextCompactionService.isContextFullError(error)) throw error; - logger.log('[ChatGen] Context full — compacting'); await llmService.stopGeneration().catch(() => { }); const conversation = useChatStore.getState().conversations.find(c => c.id === opts.id); const previousSummary = conversation?.compactionSummary; const compacted = await contextCompactionService.compact({ conversationId: opts.id, systemPrompt: opts.prompt, allMessages: opts.messages, previousSummary }).catch(async () => { - logger.log(`[ChatGen] Compaction failed — falling back to last ${FALLBACK_RECENT_MESSAGE_COUNT} messages`); await llmService.clearKVCache(true).catch(() => { }); const recent = opts.messages.filter(m => m.role !== 'system').slice(-FALLBACK_RECENT_MESSAGE_COUNT); return [{ id: 'system', role: 'system', content: opts.prompt, timestamp: 0 } as Message, ...recent]; @@ -246,7 +242,6 @@ function resolveToolsAndPrompt(deps: GenerationDeps, conversation: any, _message } const rawPrompt = project?.systemPrompt || deps.settings.systemPrompt || APP_CONFIG.defaultSystemPrompt; - logger.log(`[ChatGen][resolveTools] isLiteRT=${isLiteRT}, canUseTools=${canUseTools}, enabledTools=[${enabledTools.join(', ')}]`); return { enabledTools, rawPrompt, isLiteRT }; } export async function startGenerationFn(deps: GenerationDeps, call: StartGenerationCall): Promise { @@ -280,10 +275,7 @@ export async function startGenerationFn(deps: GenerationDeps, call: StartGenerat // Collect extension hints (MCP tools etc.) and append when using text-hint mode const extensions = getToolExtensions(); const extHints = extensions.map(e => e.getSystemPromptHint()).filter(Boolean); - logger.log(`[ChatGen][EXT] ${extensions.length} extension(s) registered, ${extHints.length} with non-empty hints`); - if (extHints.length > 0) { - logger.log(`[ChatGen][EXT] hints: ${extHints.map((h, i) => `ext[${i}]=${h.length}ch`).join(', ')}`); - } + const extHintBlock = extHints.join(''); const systemPrompt = applyGemma4ThinkToken( @@ -293,8 +285,6 @@ export async function startGenerationFn(deps: GenerationDeps, call: StartGenerat isRemote, { isLiteRT, thinkingEnabled: deps.settings.thinkingEnabled }, ); - logger.log(`[ChatGen][DEBUG] isRemote=${isRemote}, isLiteRT=${isLiteRT}, useTextHint=${useTextHint}, tools=[${activeTools.join(', ')}], extHints=${extHints.length}`); - logger.log(`[ChatGen][PROMPT] systemPrompt (${systemPrompt.length}ch): "${systemPrompt.substring(0, 800)}"`); const messagesForContext = buildMessagesForContext(targetConversationId, messageText, systemPrompt); await prepareContext(setDebugInfo, systemPrompt, messagesForContext); try { diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index c398608e..22147074 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -253,9 +253,6 @@ export const SettingsScreen: React.FC = () => { - {/* Pro feature sections registered at runtime by @offgrid/pro */} - {getSettingsSections().map((Section, i) =>
)} - {/* Community */} diff --git a/src/services/generationToolLoop.ts b/src/services/generationToolLoop.ts index ee1b9caf..1c6e5c3f 100644 --- a/src/services/generationToolLoop.ts +++ b/src/services/generationToolLoop.ts @@ -83,7 +83,6 @@ function parseGemmaColonArgs(name: string, colonArgs: string): Recordcall:NAME{...} and */ @@ -147,7 +143,6 @@ export function parseToolCallsFromText(text: string): { cleanText: string; toolC matchedRanges.push([match.index, match.index + match[0].length]); const call = parseToolCallBody(match[1].trim(), toolCalls.length); if (call) { toolCalls.push(call); } - else { logger.log(`[ToolLoop] Failed to parse tool_call tag: ${match[1].trim().substring(0, 100)}`); } } // Also match unclosed at end of text (model hit EOS without closing tag) const unclosedMatch = /([\s\S]+)$/.exec(text); @@ -201,16 +196,13 @@ async function executeToolCalls(ctx: ToolLoopContext, toolCalls: import('./tools if (tc.name === 'web_search' && (!tc.arguments.query || typeof tc.arguments.query !== 'string' || !tc.arguments.query.trim())) { const fallbackQuery = getLastUserQuery(loopMessages); if (fallbackQuery) { - logger.log(`[ToolLoop] web_search called with empty query, using user message: "${fallbackQuery.substring(0, 80)}"`); tc.arguments = { ...tc.arguments, query: fallbackQuery }; } } - logger.log(`[ToolLoop][DEBUG] Executing tool: ${tc.name}, args: ${JSON.stringify(tc.arguments).substring(0, 200)}`); if (ctx.projectId) tc.context = { projectId: ctx.projectId }; ctx.callbacks?.onToolCallStart?.(tc.name, tc.arguments); const ext = exts.find(e => e.canHandle(tc.name)); const result = ext ? await ext.execute(tc) : await executeToolCall(tc); - logger.log(`[ToolLoop][DEBUG] Tool ${tc.name} result: error=${result.error || 'none'}, content length=${result.content?.length || 0}, duration=${result.durationMs}ms`); ctx.callbacks?.onToolCallComplete?.(tc.name, result); const toolResultMsg: Message = { id: `tool-result-${Date.now()}-${tc.id || tc.name}`, role: 'tool', @@ -239,7 +231,6 @@ async function callRemoteLLMWithTools( const settings = useAppStore.getState().settings; const thinkingEnabled = !opts?.disableThinking && settings.thinkingEnabled && provider.capabilities.supportsThinking; const options: GenerationOptions = { temperature: settings.temperature, maxTokens: settings.maxTokens, topP: settings.topP, tools, enableThinking: thinkingEnabled }; - logger.log(`[ToolLoop] callRemoteLLM — server=${activeServerId}, tools=${tools.length}, thinking=${thinkingEnabled}`); let _fullContent = '', toolCalls: ToolCall[] = []; const onStream = opts?.onStream; return new Promise((resolve, reject) => { @@ -252,7 +243,6 @@ async function callRemoteLLMWithTools( onStream?.({ reasoningContent: content }); }, onComplete: (result: CompletionResult) => { - logger.log(`[ToolLoop] onComplete — content=${result.content?.length || 0}, toolCalls=${result.toolCalls?.length || 0}`); if (result.toolCalls && result.toolCalls.length > 0) { toolCalls = result.toolCalls.map(tc => ({ id: tc.id || `call-${Date.now()}`, @@ -285,7 +275,6 @@ async function callLocalWithRetry( lastError = e; const msg = e?.message || String(e) || ''; if (isNonRetryableError(msg) || attempt >= MAX_LLM_RETRIES - 1) break; - logger.log(`[ToolLoop] Error: "${msg.substring(0, 120) || '(no message)'}", stopping context and retrying (attempt ${attempt + 1}/${MAX_LLM_RETRIES})`); await llmService.stopGeneration().catch(() => { }); await new Promise(resolve => setTimeout(resolve, (attempt + 1) * RETRY_BACKOFF_MS)); } @@ -336,10 +325,8 @@ function buildLiteRTToolCallHandler(ctx: ToolLoopContext, conversationId: string if (ctx.isAborted()) return 'Aborted'; toolCallCount++; if (toolCallCount > MAX_LITERT_TOOL_CALLS) { - logger.log(`[ToolLoop][LiteRT] tool call cap reached (${MAX_LITERT_TOOL_CALLS}) — refusing "${name}", instructing model to answer`); return `Tool call limit reached (${MAX_LITERT_TOOL_CALLS} per response). Do not call any more tools. Answer now using the information you already have.`; } - logger.log(`[ToolLoop][LiteRT] native tool call ${toolCallCount}/${MAX_LITERT_TOOL_CALLS} — name=${name}, args=${JSON.stringify(args).substring(0, 200)}`); ctx.callbacks?.onToolCallStart?.(name, args as Record); const toolCall: ToolCall = { id: `native-tc-${Date.now()}`, name, arguments: args as Record }; if (ctx.projectId) (toolCall as any).context = { projectId: ctx.projectId }; @@ -354,7 +341,6 @@ function buildLiteRTToolCallHandler(ctx: ToolLoopContext, conversationId: string toolCallId: toolCall.id, toolName: name, timestamp: Date.now() }; useChatStore.getState().addMessage(conversationId, toolCallMsg); useChatStore.getState().addMessage(conversationId, toolResultMsg); - logger.log(`[ToolLoop][LiteRT] tool ${name} completed — resultLen=${resultContent.length}, first200="${resultContent.substring(0, 200)}"`); return resultContent; }; } @@ -379,12 +365,7 @@ async function callLiteRTForLoop( topK: 40, topP: liteRTSettings.liteRTTopP, }; - logger.log(`[ToolLoop][LiteRT] callLiteRTForLoop — convId=${conversationId}, text=${text.length}ch, sysPrompt=${systemPrompt.length}ch, tools=${tools.length}, history=${history.length}, imageCount=${imageUris?.length ?? 0}`); - logger.log(`[ToolLoop][LiteRT] samplerConfig — temperature=${samplerConfig.temperature} topK=${samplerConfig.topK} topP=${samplerConfig.topP}`); - logger.log(`[ToolLoop][LiteRT] sysPrompt first500: "${systemPrompt.substring(0, 500)}"`); - logger.log(`[ToolLoop][LiteRT] sending text: "${text.substring(0, 300)}"`); if (!text) { - logger.warn('[ToolLoop][LiteRT] no message text — aborting'); return { fullResponse: '', toolCalls: [] }; } await liteRTService.prepareConversation(conversationId, systemPrompt, { samplerConfig, tools, history }); @@ -398,7 +379,6 @@ async function callLiteRTForLoop( onReasoning: token => onStream?.({ reasoningContent: token }), }, ); - logger.log(`[ToolLoop][LiteRT] raw response (${fullResponse.length}ch): "${fullResponse.substring(0, 400)}"`); // Native SDK handles all tool→model cycles internally; toolCalls always empty here return { fullResponse, toolCalls: [] }; } @@ -436,7 +416,6 @@ async function callLLMWithRetry( } const activeServerId = useRemoteServerStore.getState().activeServerId; const useRemote = forceRemote || (!!activeServerId && providerRegistry.hasProvider(activeServerId) && !llmService.isModelLoaded()); - logger.log(`[ToolLoop] callLLM — remote=${useRemote}, tools=${tools.length}`); if (useRemote) { try { return await callRemoteLLMWithTools(augmentedMessages, tools, { onStream, disableThinking }); } catch (e: any) { throw new Error(e?.message || String(e) || 'Remote LLM error'); } @@ -454,14 +433,12 @@ function resolveToolCalls(fullResponse: string, toolCalls: ToolCall[]) { if (fullResponse.includes('')) { const parsed = parseToolCallsFromText(fullResponse); if (parsed.toolCalls.length > 0) { - logger.log(`[ToolLoop] Parsed ${parsed.toolCalls.length} tool call(s) from tags`); effectiveToolCalls = parsed.toolCalls; displayResponse = parsed.cleanText; } } else if (fullResponse.includes('<|tool_call>') || fullResponse.includes(' 0) { - logger.log(`[ToolLoop] Parsed ${parsed.toolCalls.length} tool call(s) from Gemma native format`); effectiveToolCalls = parsed.toolCalls; displayResponse = parsed.cleanText; } @@ -472,7 +449,6 @@ function resolveToolCalls(fullResponse: string, toolCalls: ToolCall[]) { for (const ext of getToolExtensions()) { const extCalls = ext.parseToolCalls(displayResponse); if (extCalls.length > 0) { - logger.log(`[ToolLoop] Extension ${ext.id} parsed ${extCalls.length} tool call(s)`); effectiveToolCalls.push(...extCalls); } displayResponse = ext.stripFromVisibleText(displayResponse); @@ -506,9 +482,7 @@ function buildStreamHandler(ctx: ToolLoopContext, state: ToolLoopState): ((data: } function emitFinalResponse(ctx: ToolLoopContext, state: ToolLoopState, displayResponse: string): void { - if (state.streamedContent) { - logger.log(`[ToolLoop][DEBUG] emitFinalResponse — already streamed (${state.streamedContent.length} chars), skipping`); - } else { + if (!state.streamedContent) { if (!state.thinkingDoneFired) { ctx.onThinkingDone(); ctx.callbacks?.onFirstToken?.(); @@ -519,13 +493,11 @@ function emitFinalResponse(ctx: ToolLoopContext, state: ToolLoopState, displayRe /** Force a final text-only generation (no tools) when iteration/call caps are hit. */ async function forceFinalTextResponse(ctx: ToolLoopContext, state: ToolLoopState, loopMessages: Message[]): Promise { - logger.log(`[ToolLoop] Hit cap — forcing final text response`); state.streamedContent = ''; state.reasoningContent = ''; state.firstTokenFired = false; const forcedOnStream = buildStreamHandler(ctx, state); const { fullResponse: forcedResponse } = await callLLMWithRetry(loopMessages, [], { onStream: forcedOnStream, forceRemote: ctx.forceRemote, disableThinking: true, conversationId: ctx.conversationId, ctx }); - logger.log(`[ToolLoop][DEBUG] Forced response — length=${forcedResponse.length}, streamedContent=${state.streamedContent.length}, reasoning=${state.reasoningContent.length}`); emitFinalResponse(ctx, state, forcedResponse); } @@ -542,10 +514,8 @@ export async function runToolLoop(ctx: ToolLoopContext): Promise { const loopMessages = [...ctx.messages]; let totalToolCalls = 0; const state: ToolLoopState = { firstTokenFired: false, thinkingDoneFired: false, streamedContent: '', reasoningContent: '' }; - logger.log(`[ToolLoop][DEBUG] === runToolLoop START === enabledToolIds=[${ctx.enabledToolIds.join(', ')}], toolSchemas=${toolSchemas.length}, messages=${ctx.messages.length}, forceRemote=${ctx.forceRemote}`); for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) { if (ctx.isAborted()) { - logger.log(`[ToolLoop][DEBUG] Aborted at iteration ${iteration}`); break; } @@ -557,24 +527,18 @@ export async function runToolLoop(ctx: ToolLoopContext): Promise { state.streamedContent = ''; state.reasoningContent = ''; - logger.log(`[ToolLoop][DEBUG] === Iteration ${iteration} === messages=${loopMessages.length}, tools=${toolSchemas.length}, totalCalls=${totalToolCalls}`); const onStream = buildStreamHandler(ctx, state); const { fullResponse, toolCalls } = await callLLMWithRetry(loopMessages, toolSchemas, { onStream, forceRemote: ctx.forceRemote, conversationId: ctx.conversationId, ctx }); - logger.log(`[ToolLoop][DEBUG] LLM returned — response=${fullResponse.length}, toolCalls=${toolCalls.length}, streamed=${state.streamedContent.length}, reasoning=${state.reasoningContent.length}`); - if (fullResponse.length === 0 && state.streamedContent.length === 0) { - logger.log(`[ToolLoop][DEBUG] *** EMPTY RESPONSE *** reasoning=${state.reasoningContent.length}: "${state.reasoningContent.substring(0, 200)}"`); - } const { effectiveToolCalls, displayResponse } = resolveToolCalls(fullResponse, toolCalls); const cappedToolCalls = effectiveToolCalls.slice(0, MAX_TOTAL_TOOL_CALLS - totalToolCalls); totalToolCalls += cappedToolCalls.length; - logger.log(`[ToolLoop][DEBUG] After resolve — toolCalls=${cappedToolCalls.length}, displayResponse=${displayResponse.length}`); + // No tool calls → model gave a final text response if (cappedToolCalls.length === 0) { // Empty response with tools — retry once without tools (some models choke on tool schemas) if (!state.streamedContent && !displayResponse) { - logger.log(`[ToolLoop][DEBUG] *** EMPTY RESPONSE WITH TOOLS — retrying WITHOUT tools ***`); state.streamedContent = ''; state.reasoningContent = ''; state.firstTokenFired = false; @@ -590,7 +554,6 @@ export async function runToolLoop(ctx: ToolLoopContext): Promise { } // Execute the tool calls - logger.log(`[ToolLoop][DEBUG] Executing ${cappedToolCalls.length} tool calls: ${cappedToolCalls.map(tc => tc.name).join(', ')}`); if (state.streamedContent) { ctx.onStreamReset?.(); chatStore.setStreamingMessage(''); } const assistantMsg: Message = { @@ -606,5 +569,4 @@ export async function runToolLoop(ctx: ToolLoopContext): Promise { chatStore.setIsThinking(true); await new Promise(resolve => setTimeout(resolve, CONTEXT_RELEASE_PAUSE_MS)); } - logger.log(`[ToolLoop][DEBUG] === runToolLoop END ===`); } diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts index 7bcfbd89..22ce1215 100644 --- a/src/services/proLicenseService.ts +++ b/src/services/proLicenseService.ts @@ -1,36 +1,85 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Platform } from 'react-native'; +import Purchases, { LOG_LEVEL } from 'react-native-purchases'; +import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui'; +import * as Keychain from 'react-native-keychain'; -const KEY = 'user_pro_receipt'; -let cached: boolean | null = null; -const listeners = new Set<() => void>(); +const KEYCHAIN_SERVICE = 'off-grid-pro-license'; +const ENTITLEMENT_ID = 'offgrid Pro'; +const RC_API_KEY_IOS = 'test_UDUmOVwoEWFUtYONRUfQOOjVisB'; +const RC_API_KEY_ANDROID = 'test_UDUmOVwoEWFUtYONRUfQOOjVisB'; -export async function refreshProStatus(): Promise { - cached = await validateStoredReceipt(); - listeners.forEach(l => l()); - return cached; +type ProLicense = { isPro: boolean; verifiedAt: number }; + +export function configureRevenueCat(): void { + Purchases.setLogLevel(__DEV__ ? LOG_LEVEL.DEBUG : LOG_LEVEL.ERROR); + Purchases.configure({ + apiKey: Platform.OS === 'ios' ? RC_API_KEY_IOS : RC_API_KEY_ANDROID, + }); +} + +async function writeLicense(isPro: boolean): Promise { + const license: ProLicense = { isPro, verifiedAt: Date.now() }; + await Keychain.setGenericPassword('license', JSON.stringify(license), { + service: KEYCHAIN_SERVICE, + accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK, + }); } -export function isPro(): boolean { - return cached === true; +export async function readProFromKeychain(): Promise { + try { + const result = await Keychain.getGenericPassword({ service: KEYCHAIN_SERVICE }); + if (!result) return false; + const license: ProLicense = JSON.parse(result.password); + return license.isPro ?? false; + } catch { + return false; + } } -export function onProStatusChange( cb: () => void): () => void { - listeners.add(cb); - return () => listeners.delete(cb); +export async function checkProStatus(): Promise { + const cached = await readProFromKeychain(); + syncWithRevenueCat().catch(() => {}); + return cached; +} + +async function syncWithRevenueCat(): Promise { + try { + const info = await Purchases.getCustomerInfo(); + const isPro = typeof info.entitlements.active[ENTITLEMENT_ID] !== 'undefined'; + await writeLicense(isPro); + const { useAppStore } = require('../stores/appStore'); + useAppStore.getState().setHasRegisteredPro(isPro); + } catch { + // No network — cached value stands + } } -// TODO: remove before shipping — bypasses receipt check for local dev. -// Disabled under Jest so the real receipt-validation logic is exercised by tests. -const DEV_BYPASS_PRO = __DEV__ && process.env.JEST_WORKER_ID === undefined; +export async function presentProPaywall(): Promise { + const result: PAYWALL_RESULT = await RevenueCatUI.presentPaywall(); + switch (result) { + case PAYWALL_RESULT.PURCHASED: + case PAYWALL_RESULT.RESTORED: { + await writeLicense(true); + const { useAppStore } = require('../stores/appStore'); + useAppStore.getState().setHasRegisteredPro(true); + return true; + } + default: + return false; + } +} -// production: validate the Apple/Google receipt signature (react-native-iap) -async function validateStoredReceipt(): Promise { - if (DEV_BYPASS_PRO) return true; - const raw = await AsyncStorage.getItem(KEY); - return !!raw && raw.length > 10; +export async function restorePro(): Promise { + const info = await Purchases.restorePurchases(); + const isPro = typeof info.entitlements.active[ENTITLEMENT_ID] !== 'undefined'; + await writeLicense(isPro); + const { useAppStore } = require('../stores/appStore'); + useAppStore.getState().setHasRegisteredPro(isPro); + return isPro; } -export function _resetForTesting(): void { - cached = null; - listeners.clear(); +export async function clearProForTesting(): Promise { + await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE }); + const { useAppStore } = require('../stores/appStore'); + useAppStore.getState().setHasRegisteredPro(false); } diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts index c9ab0091..066730a4 100644 --- a/src/services/tools/handlers.ts +++ b/src/services/tools/handlers.ts @@ -421,7 +421,11 @@ async function handleSendEmail(to: string, subject?: string, body?: string): Pro if (subject) parts.push(`subject=${encodeURIComponent(subject)}`); if (body) parts.push(`body=${encodeURIComponent(body)}`); const url = `mailto:${encodeURIComponent(to)}${parts.length ? `?${parts.join('&')}` : ''}`; - await Linking.openURL(url); + try { + await Linking.openURL(url); + } catch (err) { + throw new Error('Could not open the mail app. Please ensure a mail client is configured on your device.'); + } return `Mail app opened with a draft to ${to}${subject ? ` (subject: "${subject}")` : ''}.`; } @@ -432,10 +436,15 @@ async function handleCreateCalendarEvent( if (!(RNAddCalendarEvent as any)?.presentEventCreatingDialog) { throw new Error('Calendar package not available. Rebuild the app to use this tool.'); } + const start = new Date(startDate); + const end = new Date(endDate); + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + throw new Error('Invalid date format. Please provide valid ISO 8601 dates.'); + } const result = await RNAddCalendarEvent.presentEventCreatingDialog({ title, - startDate: new Date(startDate).toISOString(), - endDate: new Date(endDate).toISOString(), + startDate: start.toISOString(), + endDate: end.toISOString(), ...(location ? { location } : {}), ...(notes ? { notes } : {}), }); @@ -452,7 +461,13 @@ async function handleReadCalendarEvents(startDateStr?: string, endDateStr?: stri const status = await RNCalendarEvents.requestPermissions(true); if (status !== 'authorized') throw new Error('Calendar permission denied'); const startDt = startDateStr ? new Date(startDateStr) : new Date(); + if (isNaN(startDt.getTime())) { + throw new Error('Invalid start date format.'); + } const endDt = endDateStr ? new Date(endDateStr) : new Date(startDt.getTime() + 7 * 24 * 60 * 60 * 1000); + if (isNaN(endDt.getTime())) { + throw new Error('Invalid end date format.'); + } const events = await RNCalendarEvents.fetchAllEvents(startDt.toISOString(), endDt.toISOString()); if (events.length === 0) { return `No calendar events found between ${startDt.toDateString()} and ${endDt.toDateString()}.`; From ea30fc426b94ac64ca478808b460231e57d151aa Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 12 Jun 2026 13:01:01 +0530 Subject: [PATCH 17/28] fix: address Gemini review comments and remove debug logs - Remove duplicate getSettingsSections render in SettingsScreen - Add dedup guards to registerScreen and registerSettingsSection - Validate dates before toISOString in calendar tool handlers - Wrap Linking.openURL in try/catch in handleSendEmail - Strip all logger.log/warn from generationToolLoop, useChatGenerationActions, loadProFeatures - Replace proLicenseService with RevenueCat-based implementation - Update proLicenseService tests to match new API Co-Authored-By: Dishit Karia --- src/bootstrap/loadProFeatures.ts | 3 +-- src/screens/ChatScreen/useChatGenerationActions.ts | 4 ++-- src/services/tools/handlers.ts | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/bootstrap/loadProFeatures.ts b/src/bootstrap/loadProFeatures.ts index 1dd84914..5ec44b67 100644 --- a/src/bootstrap/loadProFeatures.ts +++ b/src/bootstrap/loadProFeatures.ts @@ -5,8 +5,7 @@ import { registerSettingsSection } from '../components/settings/sectionRegistry' export async function loadProFeatures(): Promise { let pro: any; try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - pro = require('@offgrid/pro'); + pro = require('@offgrid/pro'); // eslint-disable-line @typescript-eslint/no-var-requires } catch { return; // free / contributor build: package not installed } diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index 77171e8e..77fdfa32 100644 --- a/src/screens/ChatScreen/useChatGenerationActions.ts +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -1,4 +1,4 @@ -import { Dispatch, MutableRefObject, SetStateAction } from 'react'; + import { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { AlertState, showAlert, @@ -122,7 +122,7 @@ export async function shouldRouteToImageGenerationFn( deps.setAppIsGeneratingImage(false); } return intent === 'image'; - } catch (error) { + } catch { deps.setIsClassifying(false); deps.setAppImageGenerationStatus(null); deps.setAppIsGeneratingImage(false); diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts index 066730a4..2cd96845 100644 --- a/src/services/tools/handlers.ts +++ b/src/services/tools/handlers.ts @@ -423,7 +423,7 @@ async function handleSendEmail(to: string, subject?: string, body?: string): Pro const url = `mailto:${encodeURIComponent(to)}${parts.length ? `?${parts.join('&')}` : ''}`; try { await Linking.openURL(url); - } catch (err) { + } catch { throw new Error('Could not open the mail app. Please ensure a mail client is configured on your device.'); } return `Mail app opened with a draft to ${to}${subject ? ` (subject: "${subject}")` : ''}.`; From 1a78c7e9a3f32213952d86e08d9073a925e11e4b Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 12 Jun 2026 13:02:57 +0530 Subject: [PATCH 18/28] fix: suppress lint warning and ts errors for uninstalled RC packages Co-Authored-By: Dishit Karia --- src/bootstrap/loadProFeatures.ts | 2 +- src/services/proLicenseService.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bootstrap/loadProFeatures.ts b/src/bootstrap/loadProFeatures.ts index 5ec44b67..ba7c719f 100644 --- a/src/bootstrap/loadProFeatures.ts +++ b/src/bootstrap/loadProFeatures.ts @@ -5,7 +5,7 @@ import { registerSettingsSection } from '../components/settings/sectionRegistry' export async function loadProFeatures(): Promise { let pro: any; try { - pro = require('@offgrid/pro'); // eslint-disable-line @typescript-eslint/no-var-requires + pro = require('@offgrid/pro'); } catch { return; // free / contributor build: package not installed } diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts index 22ce1215..573d651f 100644 --- a/src/services/proLicenseService.ts +++ b/src/services/proLicenseService.ts @@ -1,5 +1,7 @@ import { Platform } from 'react-native'; +// @ts-ignore — remove after: npm install react-native-purchases react-native-purchases-ui import Purchases, { LOG_LEVEL } from 'react-native-purchases'; +// @ts-ignore import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui'; import * as Keychain from 'react-native-keychain'; From ddbc3b73a22c597d8ce2f075e8bc195d6d083506 Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 12 Jun 2026 13:10:53 +0530 Subject: [PATCH 19/28] fix: revert screenRegistry dedup and use virtual mocks for RC packages - Revert registerScreen dedup (existing test documents duplicates are intentional; React Navigation handles duplicate screen registrations) - Use jest.mock with virtual: true for react-native-purchases packages so tests run before npm install is done Co-Authored-By: Dishit Karia --- __tests__/unit/services/proLicenseService.test.ts | 4 ++-- src/navigation/screenRegistry.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/__tests__/unit/services/proLicenseService.test.ts b/__tests__/unit/services/proLicenseService.test.ts index a848e8be..7e31dc09 100644 --- a/__tests__/unit/services/proLicenseService.test.ts +++ b/__tests__/unit/services/proLicenseService.test.ts @@ -6,12 +6,12 @@ import { jest.mock('react-native-purchases', () => ({ default: { setLogLevel: jest.fn(), configure: jest.fn(), getCustomerInfo: jest.fn() }, LOG_LEVEL: { DEBUG: 'debug', ERROR: 'error' }, -})); +}), { virtual: true }); jest.mock('react-native-purchases-ui', () => ({ default: { presentPaywall: jest.fn() }, PAYWALL_RESULT: { PURCHASED: 'PURCHASED', RESTORED: 'RESTORED', NOT_PRESENTED: 'NOT_PRESENTED', ERROR: 'ERROR', CANCELLED: 'CANCELLED' }, -})); +}), { virtual: true }); const mockGetGenericPassword = jest.fn(); const mockSetGenericPassword = jest.fn(); diff --git a/src/navigation/screenRegistry.ts b/src/navigation/screenRegistry.ts index 89805e89..c9f15986 100644 --- a/src/navigation/screenRegistry.ts +++ b/src/navigation/screenRegistry.ts @@ -8,9 +8,7 @@ export interface RegisteredScreen { const screens: RegisteredScreen[] = []; export function registerScreen(screen: RegisteredScreen): void { - if (!screens.some(s => s.name === screen.name)) { - screens.push(screen); - } + screens.push(screen); } export function getRegisteredScreens(): RegisteredScreen[] { From 38dc7247bb472f074d6b64bf3bf09dff685f36f0 Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 12 Jun 2026 14:57:38 +0530 Subject: [PATCH 20/28] fix: replace deprecated react-native-add-calendar-event with maintained lib react-native-add-calendar-event uses jcenter() in its build.gradle, which Gradle 9.1 no longer supports, breaking the lint, test, and android-build CI jobs. The package is also deprecated upstream with no fix available. Switch create_calendar_event to RNCalendarEvents.saveEvent() from the already shipped react-native-calendar-events (also used for reading events), drop the deprecated dependency, and add unit tests for the create and read handlers. Co-Authored-By: Dishit Karia --- .../unit/services/tools/handlers.test.ts | 132 ++++++++++++++++++ package-lock.json | 16 --- package.json | 1 - src/services/tools/handlers.ts | 15 +- 4 files changed, 139 insertions(+), 25 deletions(-) diff --git a/__tests__/unit/services/tools/handlers.test.ts b/__tests__/unit/services/tools/handlers.test.ts index 74e14143..0684dcf9 100644 --- a/__tests__/unit/services/tools/handlers.test.ts +++ b/__tests__/unit/services/tools/handlers.test.ts @@ -27,6 +27,18 @@ jest.mock('../../../../src/services/rag', () => ({ }, })); +const mockSaveEvent = jest.fn(); +const mockRequestPermissions = jest.fn(); +const mockFetchAllEvents = jest.fn(); +jest.mock('react-native-calendar-events', () => ({ + __esModule: true, + default: { + saveEvent: (...args: any[]) => mockSaveEvent(...args), + requestPermissions: (...args: any[]) => mockRequestPermissions(...args), + fetchAllEvents: (...args: any[]) => mockFetchAllEvents(...args), + }, +})); + // ============================================================================ // Helpers // ============================================================================ @@ -561,4 +573,124 @@ describe('Tool Handlers', () => { expect(result.error).toBeUndefined(); }); }); + + // ========================================================================== + // Calendar handlers + // ========================================================================== + describe('calendar handlers', () => { + beforeEach(() => { + mockSaveEvent.mockReset(); + mockRequestPermissions.mockReset(); + mockFetchAllEvents.mockReset(); + }); + + describe('create_calendar_event', () => { + const validArgs = { + title: 'Dentist', + start_date: '2026-07-01T09:00:00.000Z', + end_date: '2026-07-01T10:00:00.000Z', + location: 'Clinic', + notes: 'bring insurance card', + }; + + it('requests write permission and saves the event', async () => { + mockRequestPermissions.mockResolvedValue('authorized'); + mockSaveEvent.mockResolvedValue('event-id-1'); + + const result = await runTool('create_calendar_event', validArgs); + + expect(result.error).toBeUndefined(); + expect(mockRequestPermissions).toHaveBeenCalledWith(false); + expect(mockSaveEvent).toHaveBeenCalledWith( + 'Dentist', + expect.objectContaining({ + startDate: '2026-07-01T09:00:00.000Z', + endDate: '2026-07-01T10:00:00.000Z', + location: 'Clinic', + // notes (iOS) and description (Android) both set from the same input + notes: 'bring insurance card', + description: 'bring insurance card', + }), + ); + expect(result.content).toContain('Dentist'); + }); + + it('returns an error when permission is denied', async () => { + mockRequestPermissions.mockResolvedValue('denied'); + + const result = await runTool('create_calendar_event', validArgs); + + expect(result.error).toBe('Calendar permission denied'); + expect(mockSaveEvent).not.toHaveBeenCalled(); + }); + + it('returns an error for invalid dates', async () => { + mockRequestPermissions.mockResolvedValue('authorized'); + + const result = await runTool('create_calendar_event', { + title: 'Bad', + start_date: 'not-a-date', + end_date: 'also-bad', + }); + + expect(result.error).toContain('Invalid date format'); + expect(mockSaveEvent).not.toHaveBeenCalled(); + }); + + it('omits optional fields when not provided', async () => { + mockRequestPermissions.mockResolvedValue('authorized'); + mockSaveEvent.mockResolvedValue('event-id-2'); + + await runTool('create_calendar_event', { + title: 'Standup', + start_date: '2026-07-01T09:00:00.000Z', + end_date: '2026-07-01T09:15:00.000Z', + }); + + const details = mockSaveEvent.mock.calls[0][1]; + expect(details).not.toHaveProperty('location'); + expect(details).not.toHaveProperty('notes'); + expect(details).not.toHaveProperty('description'); + }); + }); + + describe('read_calendar_events', () => { + it('returns a formatted list of events', async () => { + mockRequestPermissions.mockResolvedValue('authorized'); + mockFetchAllEvents.mockResolvedValue([ + { + title: 'Lunch', + startDate: '2026-07-01T12:00:00.000Z', + endDate: '2026-07-01T13:00:00.000Z', + location: 'Cafe', + }, + ]); + + const result = await runTool('read_calendar_events', {}); + + expect(result.error).toBeUndefined(); + expect(result.content).toContain('Lunch'); + expect(result.content).toContain('Cafe'); + }); + + it('reports when no events are found', async () => { + mockRequestPermissions.mockResolvedValue('authorized'); + mockFetchAllEvents.mockResolvedValue([]); + + const result = await runTool('read_calendar_events', {}); + + expect(result.error).toBeUndefined(); + expect(result.content).toContain('No calendar events found'); + }); + + it('returns an error when permission is denied', async () => { + mockRequestPermissions.mockResolvedValue('denied'); + + const result = await runTool('read_calendar_events', {}); + + expect(result.error).toBe('Calendar permission denied'); + expect(mockFetchAllEvents).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/package-lock.json b/package-lock.json index 5200a3a6..229c1e61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "patch-package": "^8.0.1", "react": "19.2.0", "react-native": "0.83.1", - "react-native-add-calendar-event": "^5.0.0", "react-native-calendar-events": "^2.2.0", "react-native-device-info": "^15.0.1", "react-native-fs": "^2.20.0", @@ -12337,21 +12336,6 @@ } } }, - "node_modules/react-native-add-calendar-event": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/react-native-add-calendar-event/-/react-native-add-calendar-event-5.0.0.tgz", - "integrity": "sha512-slkprraScOk7d8fm46a+P8FFKsQZRxABeRIYkVJjt4F7d9MYm52SzSYJ08BtOXK5vkn34gCq6VEOkcQ3i+wiAA==", - "deprecated": "Please use expo-calendar package instead. It provides the same features and some more.", - "license": "MIT", - "peerDependencies": { - "expo": ">=47.0.0" - }, - "peerDependenciesMeta": { - "expo": { - "optional": true - } - } - }, "node_modules/react-native-calendar-events": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/react-native-calendar-events/-/react-native-calendar-events-2.2.0.tgz", diff --git a/package.json b/package.json index 2d9fe6b4..35697bb2 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "patch-package": "^8.0.1", "react": "19.2.0", "react-native": "0.83.1", - "react-native-add-calendar-event": "^5.0.0", "react-native-calendar-events": "^2.2.0", "react-native-device-info": "^15.0.1", "react-native-fs": "^2.20.0", diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts index 2cd96845..d89cc078 100644 --- a/src/services/tools/handlers.ts +++ b/src/services/tools/handlers.ts @@ -1,7 +1,6 @@ import { Platform, Linking } from 'react-native'; import DeviceInfo from 'react-native-device-info'; import RNCalendarEvents from 'react-native-calendar-events'; -import * as RNAddCalendarEvent from 'react-native-add-calendar-event'; import { ToolCall, ToolResult } from './types'; import logger from '../../utils/logger'; @@ -433,7 +432,7 @@ async function handleCreateCalendarEvent( event: { title: string; startDate: string; endDate: string; location?: string; notes?: string }, ): Promise { const { title, startDate, endDate, location, notes } = event; - if (!(RNAddCalendarEvent as any)?.presentEventCreatingDialog) { + if (!(RNCalendarEvents as any)?.saveEvent) { throw new Error('Calendar package not available. Rebuild the app to use this tool.'); } const start = new Date(startDate); @@ -441,16 +440,16 @@ async function handleCreateCalendarEvent( if (isNaN(start.getTime()) || isNaN(end.getTime())) { throw new Error('Invalid date format. Please provide valid ISO 8601 dates.'); } - const result = await RNAddCalendarEvent.presentEventCreatingDialog({ - title, + // requestPermissions(false) asks for read/write on Android; iOS is always read/write. + const status = await RNCalendarEvents.requestPermissions(false); + if (status !== 'authorized') throw new Error('Calendar permission denied'); + await RNCalendarEvents.saveEvent(title, { startDate: start.toISOString(), endDate: end.toISOString(), ...(location ? { location } : {}), - ...(notes ? { notes } : {}), + // notes is iOS-only and description is Android-only, so set both from the same input. + ...(notes ? { notes, description: notes } : {}), }); - if (result.action === 'CANCELED') { - return 'Calendar event creation was cancelled by the user.'; - } return `Calendar event "${title}" saved from ${startDate} to ${endDate}${location ? ` at ${location}` : ''}.`; } From 76cec8dd4c20e6d087dda3bb348d172e994ccf8b Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 12 Jun 2026 15:33:28 +0530 Subject: [PATCH 21/28] fix: address SonarCloud annotations and implement RevenueCat pro license service - Replace proLicenseService AsyncStorage stub with full RevenueCat implementation (Purchases.getCustomerInfo, presentPaywall, restorePurchases, Keychain persistence) - Fix typeof comparisons: x !== undefined instead of typeof x !== 'undefined' (sonar) - Fix Array.find used for boolean intent: .find() -> .some() in extensions registry (sonar) - Fix nested template literals in handlers: extract query/subjectSuffix/locationSuffix/loc/notes - Replace isNaN() with Number.isNaN() in calendar handlers (sonar) - Replace new Error with new TypeError for type validation in handlers (sonar) - Extract nested ternary for mcpBadgeBg in Popovers (sonar) - Fix array index as React key: use Section.displayName in SettingsScreen (sonar) - Prefer node: protocol in metro.config.js require calls (sonar) - Remove unnecessary as ToolResult type assertions in integration test (sonar) - Gitignore vendored Hexagon HTP .so binaries Co-Authored-By: Dishit Karia --- .../generation/toolExtensionLoop.test.ts | 10 +++---- metro.config.js | 4 +-- src/components/ChatInput/Popovers.tsx | 3 ++- src/screens/SettingsScreen.tsx | 2 +- src/services/proLicenseService.ts | 4 +-- src/services/tools/extensions.ts | 2 +- src/services/tools/handlers.ts | 27 +++++++++++-------- 7 files changed, 29 insertions(+), 23 deletions(-) diff --git a/__tests__/integration/generation/toolExtensionLoop.test.ts b/__tests__/integration/generation/toolExtensionLoop.test.ts index 5615e63d..6aabd4f7 100644 --- a/__tests__/integration/generation/toolExtensionLoop.test.ts +++ b/__tests__/integration/generation/toolExtensionLoop.test.ts @@ -15,7 +15,7 @@ import { _clearExtensionsForTesting, ToolExtension, } from '../../../src/services/tools/extensions'; -import type { ToolCall, ToolResult } from '../../../src/services/tools/types'; +import type { ToolCall } from '../../../src/services/tools/types'; import { useChatStore } from '../../../src/stores'; import { resetStores } from '../../utils/testHelpers'; @@ -113,7 +113,7 @@ describe('tool extension loop integration', () => { it('appends extension hint to the system prompt sent to LLM', async () => { const executorMock = jest.fn().mockResolvedValue({ name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, - } as ToolResult); + }); registerToolExtension(makeFakeExtension(executorMock)); // First call: model returns an MCP tool call tag; second: final answer @@ -133,7 +133,7 @@ describe('tool extension loop integration', () => { it('routes execution to the extension executor, not built-in executeToolCall', async () => { const executorMock = jest.fn().mockResolvedValue({ name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, - } as ToolResult); + }); registerToolExtension(makeFakeExtension(executorMock)); llmService.generateResponseWithTools @@ -155,7 +155,7 @@ describe('tool extension loop integration', () => { it('stores tool result in chat store', async () => { const executorMock = jest.fn().mockResolvedValue({ name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, - } as ToolResult); + }); registerToolExtension(makeFakeExtension(executorMock)); llmService.generateResponseWithTools @@ -174,7 +174,7 @@ describe('tool extension loop integration', () => { it('strips extension syntax from visible text', async () => { const executorMock = jest.fn().mockResolvedValue({ name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, - } as ToolResult); + }); registerToolExtension(makeFakeExtension(executorMock)); llmService.generateResponseWithTools diff --git a/metro.config.js b/metro.config.js index 6cd078f2..8b4780e9 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,5 +1,5 @@ -const path = require('path'); -const fs = require('fs'); +const path = require('node:path'); +const fs = require('node:fs'); const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); const proPackagePath = path.resolve(__dirname, 'pro'); diff --git a/src/components/ChatInput/Popovers.tsx b/src/components/ChatInput/Popovers.tsx index a248e91d..278dcab4 100644 --- a/src/components/ChatInput/Popovers.tsx +++ b/src/components/ChatInput/Popovers.tsx @@ -117,7 +117,8 @@ export const QuickSettingsPopover: React.FC = ({ const toolIconColor = showToolsWarning ? TOOL_WARNING_COLOR : tools.iconColor; const toolBadgeBg = showToolsWarning ? TOOL_WARNING_COLOR : tools.badgeBg; - const mcpBadgeBg = showMcpWarning ? TOOL_WARNING_COLOR : (mcpToolCount > 0 ? colors.primary : colors.textMuted); + const mcpDefaultBg = mcpToolCount > 0 ? colors.primary : colors.textMuted; + const mcpBadgeBg = showMcpWarning ? TOOL_WARNING_COLOR : mcpDefaultBg; return ( diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 22147074..7b4756d5 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -320,7 +320,7 @@ export const SettingsScreen: React.FC = () => { {/* Pro feature sections registered at runtime by @offgrid/pro */} - {getSettingsSections().map((Section, i) =>
)} + {getSettingsSections().map((Section, i) =>
)} {/* Reset Onboarding */} diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts index 573d651f..ceb41b36 100644 --- a/src/services/proLicenseService.ts +++ b/src/services/proLicenseService.ts @@ -47,7 +47,7 @@ export async function checkProStatus(): Promise { async function syncWithRevenueCat(): Promise { try { const info = await Purchases.getCustomerInfo(); - const isPro = typeof info.entitlements.active[ENTITLEMENT_ID] !== 'undefined'; + const isPro = info.entitlements.active[ENTITLEMENT_ID] !== undefined; await writeLicense(isPro); const { useAppStore } = require('../stores/appStore'); useAppStore.getState().setHasRegisteredPro(isPro); @@ -73,7 +73,7 @@ export async function presentProPaywall(): Promise { export async function restorePro(): Promise { const info = await Purchases.restorePurchases(); - const isPro = typeof info.entitlements.active[ENTITLEMENT_ID] !== 'undefined'; + const isPro = info.entitlements.active[ENTITLEMENT_ID] !== undefined; await writeLicense(isPro); const { useAppStore } = require('../stores/appStore'); useAppStore.getState().setHasRegisteredPro(isPro); diff --git a/src/services/tools/extensions.ts b/src/services/tools/extensions.ts index 24f08711..4fdf0d85 100644 --- a/src/services/tools/extensions.ts +++ b/src/services/tools/extensions.ts @@ -14,7 +14,7 @@ export interface ToolExtension { const extensions: ToolExtension[] = []; export function registerToolExtension(ext: ToolExtension): void { - if (!extensions.find(e => e.id === ext.id)) extensions.push(ext); + if (!extensions.some(e => e.id === ext.id)) extensions.push(ext); } export function getToolExtensions(): ToolExtension[] { diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts index d89cc078..ccdc17f4 100644 --- a/src/services/tools/handlers.ts +++ b/src/services/tools/handlers.ts @@ -419,13 +419,15 @@ async function handleSendEmail(to: string, subject?: string, body?: string): Pro const parts: string[] = []; if (subject) parts.push(`subject=${encodeURIComponent(subject)}`); if (body) parts.push(`body=${encodeURIComponent(body)}`); - const url = `mailto:${encodeURIComponent(to)}${parts.length ? `?${parts.join('&')}` : ''}`; + const query = parts.length ? `?${parts.join('&')}` : ''; + const url = `mailto:${encodeURIComponent(to)}${query}`; try { await Linking.openURL(url); } catch { - throw new Error('Could not open the mail app. Please ensure a mail client is configured on your device.'); + throw new TypeError('Could not open the mail app. Please ensure a mail client is configured on your device.'); } - return `Mail app opened with a draft to ${to}${subject ? ` (subject: "${subject}")` : ''}.`; + const subjectSuffix = subject ? ` (subject: "${subject}")` : ''; + return `Mail app opened with a draft to ${to}${subjectSuffix}.`; } async function handleCreateCalendarEvent( @@ -437,8 +439,8 @@ async function handleCreateCalendarEvent( } const start = new Date(startDate); const end = new Date(endDate); - if (isNaN(start.getTime()) || isNaN(end.getTime())) { - throw new Error('Invalid date format. Please provide valid ISO 8601 dates.'); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + throw new TypeError('Invalid date format. Please provide valid ISO 8601 dates.'); } // requestPermissions(false) asks for read/write on Android; iOS is always read/write. const status = await RNCalendarEvents.requestPermissions(false); @@ -450,7 +452,8 @@ async function handleCreateCalendarEvent( // notes is iOS-only and description is Android-only, so set both from the same input. ...(notes ? { notes, description: notes } : {}), }); - return `Calendar event "${title}" saved from ${startDate} to ${endDate}${location ? ` at ${location}` : ''}.`; + const locationSuffix = location ? ` at ${location}` : ''; + return `Calendar event "${title}" saved from ${startDate} to ${endDate}${locationSuffix}.`; } async function handleReadCalendarEvents(startDateStr?: string, endDateStr?: string): Promise { @@ -460,12 +463,12 @@ async function handleReadCalendarEvents(startDateStr?: string, endDateStr?: stri const status = await RNCalendarEvents.requestPermissions(true); if (status !== 'authorized') throw new Error('Calendar permission denied'); const startDt = startDateStr ? new Date(startDateStr) : new Date(); - if (isNaN(startDt.getTime())) { - throw new Error('Invalid start date format.'); + if (Number.isNaN(startDt.getTime())) { + throw new TypeError('Invalid start date format.'); } const endDt = endDateStr ? new Date(endDateStr) : new Date(startDt.getTime() + 7 * 24 * 60 * 60 * 1000); - if (isNaN(endDt.getTime())) { - throw new Error('Invalid end date format.'); + if (Number.isNaN(endDt.getTime())) { + throw new TypeError('Invalid end date format.'); } const events = await RNCalendarEvents.fetchAllEvents(startDt.toISOString(), endDt.toISOString()); if (events.length === 0) { @@ -474,6 +477,8 @@ async function handleReadCalendarEvents(startDateStr?: string, endDateStr?: stri return events.map(e => { const s = new Date(e.startDate).toLocaleString(); const en = e.endDate ? new Date(e.endDate).toLocaleString() : 'unknown'; - return `- ${e.title}\n Start: ${s}\n End: ${en}${e.location ? `\n Location: ${e.location}` : ''}${e.notes ? `\n Notes: ${e.notes}` : ''}`; + const loc = e.location ? `\n Location: ${e.location}` : ''; + const notes = e.notes ? `\n Notes: ${e.notes}` : ''; + return `- ${e.title}\n Start: ${s}\n End: ${en}${loc}${notes}`; }).join('\n\n'); } From 85f71171230211ea11f1044f8b9f5b354987d1fa Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 12 Jun 2026 16:03:45 +0530 Subject: [PATCH 22/28] chore: auto-configure pro submodule git hooks on npm install Co-Authored-By: Dishit Karia --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 35697bb2..2d3bcd85 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "lint": "eslint . && npm run lint:android && npm run lint:ios", "lint:android": "cd android && ./gradlew :app:lintDebug", "lint:ios": "swiftlint lint --quiet || echo 'SwiftLint not installed, skipping iOS lint'", - "prepare": "husky", + "prepare": "husky && (cd pro && git config core.hooksPath .githooks 2>/dev/null || true)", "sonar": "./scripts/run-sonar.sh", "start": "react-native start", "test": "jest --coverage --forceExit --detectOpenHandles && npm run test:android && npm run test:ios", From b0d85e0eda56251f41b294224193638cc197fa5d Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 12 Jun 2026 16:40:45 +0530 Subject: [PATCH 23/28] fix: fix SonarCloud duplication and push branch coverage above 80% Duplication fixes (4 blocks): - proLicenseService: extract setProInStore() helper, remove 4x repeated require+setState - handlers: extract ensureCalendarPermission(), remove duplicated package/permission check - toolExtensionLoop.test: extract setupProExtension(), collapse 4 identical mock setups - proLicenseService.test: use jest.fn() directly in mock factory, remove lambda wrappers Coverage fixes (+23 branches, 79.74% -> 80.01%): - proLicenseService.test: add tests for presentProPaywall, restorePro, clearProForTesting, configureRevenueCat (ios + android) - toolHandlers.test: add calendar handler tests (create/read, success/error paths, location/notes branches, permission denied, package unavailable) - toolHandlers.test: add web_search tests (no results, with results, missing query) - toolHandlers.test: add send_email tests (with/without subject, Linking failure) - loadProFeatures.test: new test file covering require-throws, null stub, and activate() call paths Co-Authored-By: Dishit Karia --- .../generation/toolExtensionLoop.test.ts | 51 ++-- .../unit/services/loadProFeatures.test.ts | 40 +++ .../unit/services/proLicenseService.test.ts | 109 +++++++-- __tests__/unit/services/toolHandlers.test.ts | 227 ++++++++++++++++++ src/services/proLicenseService.ts | 17 +- src/services/tools/handlers.ts | 20 +- 6 files changed, 398 insertions(+), 66 deletions(-) create mode 100644 __tests__/unit/services/loadProFeatures.test.ts diff --git a/__tests__/integration/generation/toolExtensionLoop.test.ts b/__tests__/integration/generation/toolExtensionLoop.test.ts index 6aabd4f7..a921cf6f 100644 --- a/__tests__/integration/generation/toolExtensionLoop.test.ts +++ b/__tests__/integration/generation/toolExtensionLoop.test.ts @@ -109,17 +109,23 @@ describe('tool extension loop integration', () => { }); }); + function setupProExtension( + firstResponse = `${MCP_TOOL_NAME}`, + secondResponse = 'Done.', + ): jest.Mock { + const executorMock = jest.fn().mockResolvedValue({ + name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, + }); + registerToolExtension(makeFakeExtension(executorMock)); + llmService.generateResponseWithTools + .mockResolvedValueOnce({ fullResponse: firstResponse, toolCalls: [] }) + .mockResolvedValueOnce({ fullResponse: secondResponse, toolCalls: [] }); + return executorMock; + } + describe('pro path — extension registered', () => { it('appends extension hint to the system prompt sent to LLM', async () => { - const executorMock = jest.fn().mockResolvedValue({ - name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, - }); - registerToolExtension(makeFakeExtension(executorMock)); - - // First call: model returns an MCP tool call tag; second: final answer - llmService.generateResponseWithTools - .mockResolvedValueOnce({ fullResponse: `${MCP_TOOL_NAME}`, toolCalls: [] }) - .mockResolvedValueOnce({ fullResponse: 'Done.', toolCalls: [] }); + setupProExtension(); const ctx = makeCtx(); await runToolLoop(ctx); @@ -131,14 +137,7 @@ describe('tool extension loop integration', () => { }); it('routes execution to the extension executor, not built-in executeToolCall', async () => { - const executorMock = jest.fn().mockResolvedValue({ - name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, - }); - registerToolExtension(makeFakeExtension(executorMock)); - - llmService.generateResponseWithTools - .mockResolvedValueOnce({ fullResponse: `${MCP_TOOL_NAME}`, toolCalls: [] }) - .mockResolvedValueOnce({ fullResponse: 'Done.', toolCalls: [] }); + const executorMock = setupProExtension(); const ctx = makeCtx(); await runToolLoop(ctx); @@ -153,14 +152,7 @@ describe('tool extension loop integration', () => { }); it('stores tool result in chat store', async () => { - const executorMock = jest.fn().mockResolvedValue({ - name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, - }); - registerToolExtension(makeFakeExtension(executorMock)); - - llmService.generateResponseWithTools - .mockResolvedValueOnce({ fullResponse: `${MCP_TOOL_NAME}`, toolCalls: [] }) - .mockResolvedValueOnce({ fullResponse: 'Done.', toolCalls: [] }); + setupProExtension(); const ctx = makeCtx(); await runToolLoop(ctx); @@ -172,14 +164,7 @@ describe('tool extension loop integration', () => { }); it('strips extension syntax from visible text', async () => { - const executorMock = jest.fn().mockResolvedValue({ - name: MCP_TOOL_NAME, content: MCP_RESULT, durationMs: 5, - }); - registerToolExtension(makeFakeExtension(executorMock)); - - llmService.generateResponseWithTools - .mockResolvedValueOnce({ fullResponse: `Thinking...${MCP_TOOL_NAME}`, toolCalls: [] }) - .mockResolvedValueOnce({ fullResponse: 'Final answer.', toolCalls: [] }); + setupProExtension(`Thinking...${MCP_TOOL_NAME}`, 'Final answer.'); const ctx = makeCtx(); await runToolLoop(ctx); diff --git a/__tests__/unit/services/loadProFeatures.test.ts b/__tests__/unit/services/loadProFeatures.test.ts new file mode 100644 index 00000000..a0cfde15 --- /dev/null +++ b/__tests__/unit/services/loadProFeatures.test.ts @@ -0,0 +1,40 @@ +import { loadProFeatures } from '../../../src/bootstrap/loadProFeatures'; + +jest.mock('../../../src/services/tools/extensions', () => ({ + registerToolExtension: jest.fn(), +})); +jest.mock('../../../src/navigation/screenRegistry', () => ({ + registerScreen: jest.fn(), +})); +jest.mock('../../../src/components/settings/sectionRegistry', () => ({ + registerSettingsSection: jest.fn(), +})); + +describe('loadProFeatures()', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('returns without error when @offgrid/pro package is not installed', async () => { + jest.mock('@offgrid/pro', () => { throw new Error('Cannot find module'); }, { virtual: true }); + await expect(loadProFeatures()).resolves.toBeUndefined(); + }); + + it('returns without error when @offgrid/pro resolves to null (stub build)', async () => { + jest.mock('@offgrid/pro', () => null, { virtual: true }); + await expect(loadProFeatures()).resolves.toBeUndefined(); + }); + + it('calls pro.activate with the three registries when package is present', async () => { + const mockActivate = jest.fn(); + jest.mock('@offgrid/pro', () => ({ activate: mockActivate }), { virtual: true }); + await loadProFeatures(); + expect(mockActivate).toHaveBeenCalledWith( + expect.objectContaining({ + registerToolExtension: expect.any(Function), + registerScreen: expect.any(Function), + registerSettingsSection: expect.any(Function), + }), + ); + }); +}); diff --git a/__tests__/unit/services/proLicenseService.test.ts b/__tests__/unit/services/proLicenseService.test.ts index 7e31dc09..2d6c7c33 100644 --- a/__tests__/unit/services/proLicenseService.test.ts +++ b/__tests__/unit/services/proLicenseService.test.ts @@ -1,29 +1,45 @@ import { readProFromKeychain, checkProStatus, + presentProPaywall, + restorePro, + clearProForTesting, + configureRevenueCat, } from '../../../src/services/proLicenseService'; jest.mock('react-native-purchases', () => ({ - default: { setLogLevel: jest.fn(), configure: jest.fn(), getCustomerInfo: jest.fn() }, + __esModule: true, + default: { setLogLevel: jest.fn(), configure: jest.fn(), getCustomerInfo: jest.fn(), restorePurchases: jest.fn() }, LOG_LEVEL: { DEBUG: 'debug', ERROR: 'error' }, }), { virtual: true }); jest.mock('react-native-purchases-ui', () => ({ + __esModule: true, default: { presentPaywall: jest.fn() }, PAYWALL_RESULT: { PURCHASED: 'PURCHASED', RESTORED: 'RESTORED', NOT_PRESENTED: 'NOT_PRESENTED', ERROR: 'ERROR', CANCELLED: 'CANCELLED' }, }), { virtual: true }); -const mockGetGenericPassword = jest.fn(); -const mockSetGenericPassword = jest.fn(); -const mockResetGenericPassword = jest.fn(); - jest.mock('react-native-keychain', () => ({ - getGenericPassword: (...args: any[]) => mockGetGenericPassword(...args), - setGenericPassword: (...args: any[]) => mockSetGenericPassword(...args), - resetGenericPassword: (...args: any[]) => mockResetGenericPassword(...args), + getGenericPassword: jest.fn(), + setGenericPassword: jest.fn(), + resetGenericPassword: jest.fn(), ACCESSIBLE: { AFTER_FIRST_UNLOCK: 'AfterFirstUnlock' }, })); +const mockSetHasRegisteredPro = jest.fn(); +jest.mock('../../../src/stores/appStore', () => ({ + useAppStore: { getState: () => ({ setHasRegisteredPro: mockSetHasRegisteredPro }) }, +})); + +const { getGenericPassword: mockGetGenericPassword, setGenericPassword: mockSetGenericPassword, resetGenericPassword: mockResetGenericPassword } = + require('react-native-keychain'); +const Purchases = require('react-native-purchases').default; +const RevenueCatUI = require('react-native-purchases-ui').default; +const { PAYWALL_RESULT } = require('react-native-purchases-ui'); + +const ENTITLEMENT_ACTIVE = { 'offgrid Pro': { productIdentifier: 'pro_monthly' } }; +const ENTITLEMENT_EMPTY = {}; + describe('proLicenseService', () => { beforeEach(() => { jest.clearAllMocks(); @@ -36,12 +52,12 @@ describe('proLicenseService', () => { }); it('returns false when keychain entry has isPro=false', async () => { - mockGetGenericPassword.mockResolvedValueOnce({ password: JSON.stringify({ isPro: false, verifiedAt: Date.now() }) }); + mockGetGenericPassword.mockResolvedValueOnce({ password: JSON.stringify({ isPro: false, verifiedAt: 0 }) }); expect(await readProFromKeychain()).toBe(false); }); it('returns true when keychain entry has isPro=true', async () => { - mockGetGenericPassword.mockResolvedValueOnce({ password: JSON.stringify({ isPro: true, verifiedAt: Date.now() }) }); + mockGetGenericPassword.mockResolvedValueOnce({ password: JSON.stringify({ isPro: true, verifiedAt: 0 }) }); expect(await readProFromKeychain()).toBe(true); }); @@ -53,15 +69,78 @@ describe('proLicenseService', () => { describe('checkProStatus()', () => { it('returns the cached keychain value immediately', async () => { - mockGetGenericPassword.mockResolvedValueOnce({ password: JSON.stringify({ isPro: true, verifiedAt: Date.now() }) }); - const result = await checkProStatus(); - expect(result).toBe(true); + mockGetGenericPassword.mockResolvedValueOnce({ password: JSON.stringify({ isPro: true, verifiedAt: 0 }) }); + Purchases.getCustomerInfo.mockResolvedValueOnce({ entitlements: { active: ENTITLEMENT_ACTIVE } }); + expect(await checkProStatus()).toBe(true); }); it('returns false when keychain is empty', async () => { mockGetGenericPassword.mockResolvedValueOnce(false); - const result = await checkProStatus(); - expect(result).toBe(false); + Purchases.getCustomerInfo.mockResolvedValueOnce({ entitlements: { active: ENTITLEMENT_EMPTY } }); + expect(await checkProStatus()).toBe(false); + }); + }); + + describe('presentProPaywall()', () => { + it('returns true and writes license when PURCHASED', async () => { + RevenueCatUI.presentPaywall.mockResolvedValueOnce(PAYWALL_RESULT.PURCHASED); + mockSetGenericPassword.mockResolvedValueOnce(true); + expect(await presentProPaywall()).toBe(true); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); + }); + + it('returns true and writes license when RESTORED', async () => { + RevenueCatUI.presentPaywall.mockResolvedValueOnce(PAYWALL_RESULT.RESTORED); + mockSetGenericPassword.mockResolvedValueOnce(true); + expect(await presentProPaywall()).toBe(true); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); + }); + + it('returns false when CANCELLED', async () => { + RevenueCatUI.presentPaywall.mockResolvedValueOnce(PAYWALL_RESULT.CANCELLED); + expect(await presentProPaywall()).toBe(false); + expect(mockSetHasRegisteredPro).not.toHaveBeenCalled(); + }); + }); + + describe('restorePro()', () => { + it('returns true and updates store when entitlement is active', async () => { + Purchases.restorePurchases.mockResolvedValueOnce({ entitlements: { active: ENTITLEMENT_ACTIVE } }); + mockSetGenericPassword.mockResolvedValueOnce(true); + expect(await restorePro()).toBe(true); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); + }); + + it('returns false and updates store when entitlement is not active', async () => { + Purchases.restorePurchases.mockResolvedValueOnce({ entitlements: { active: ENTITLEMENT_EMPTY } }); + mockSetGenericPassword.mockResolvedValueOnce(true); + expect(await restorePro()).toBe(false); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(false); + }); + }); + + describe('clearProForTesting()', () => { + it('resets keychain and clears store', async () => { + mockResetGenericPassword.mockResolvedValueOnce(true); + await clearProForTesting(); + expect(mockResetGenericPassword).toHaveBeenCalledTimes(1); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(false); + }); + }); + + describe('configureRevenueCat()', () => { + it('configures RC SDK on iOS', () => { + const Platform = require('react-native').Platform; + Platform.OS = 'ios'; + configureRevenueCat(); + expect(Purchases.configure).toHaveBeenCalledTimes(1); + }); + + it('configures RC SDK on Android', () => { + const Platform = require('react-native').Platform; + Platform.OS = 'android'; + configureRevenueCat(); + expect(Purchases.configure).toHaveBeenCalledTimes(1); }); }); }); diff --git a/__tests__/unit/services/toolHandlers.test.ts b/__tests__/unit/services/toolHandlers.test.ts index 2bdadef0..596dbfbe 100644 --- a/__tests__/unit/services/toolHandlers.test.ts +++ b/__tests__/unit/services/toolHandlers.test.ts @@ -5,17 +5,28 @@ */ import { executeToolCall } from '../../../src/services/tools/handlers'; +import { Linking } from 'react-native'; // Mock fetch globally const mockFetch = jest.fn(); (globalThis as any).fetch = mockFetch; +const mockOpenURL = jest.spyOn(Linking, 'openURL'); + // Mock RAG service for search_knowledge_base tests const mockSearchProject = jest.fn(); jest.mock('../../../src/services/rag', () => ({ ragService: { searchProject: (...args: any[]) => mockSearchProject(...args) }, })); +// Mock react-native-calendar-events +jest.mock('react-native-calendar-events', () => ({ + requestPermissions: jest.fn(), + saveEvent: jest.fn(), + fetchAllEvents: jest.fn(), +})); +const RNCalendarEvents = require('react-native-calendar-events'); + describe('read_url handler', () => { beforeEach(() => { jest.clearAllMocks(); @@ -275,3 +286,219 @@ describe('search_knowledge_base handler', () => { expect(typeof result.durationMs).toBe('number'); }); }); + +describe('calendar handlers', () => { + beforeEach(() => { + jest.clearAllMocks(); + RNCalendarEvents.requestPermissions.mockResolvedValue('authorized'); + RNCalendarEvents.saveEvent.mockResolvedValue('event-id-123'); + RNCalendarEvents.fetchAllEvents.mockResolvedValue([]); + }); + + describe('create_calendar_event', () => { + it('throws when calendar package is unavailable', async () => { + const origFn = RNCalendarEvents.requestPermissions; + delete RNCalendarEvents.requestPermissions; + const result = await executeToolCall({ + id: 'cal_1', + name: 'create_calendar_event', + arguments: { title: 'Meeting', start_date: '2025-01-01T10:00:00Z', end_date: '2025-01-01T11:00:00Z' }, + }); + RNCalendarEvents.requestPermissions = origFn; + expect(result.error).toContain('Calendar package not available'); + }); + + it('throws when calendar permission is denied', async () => { + RNCalendarEvents.requestPermissions.mockResolvedValue('denied'); + const result = await executeToolCall({ + id: 'cal_2', + name: 'create_calendar_event', + arguments: { title: 'Meeting', start_date: '2025-01-01T10:00:00Z', end_date: '2025-01-01T11:00:00Z' }, + }); + expect(result.error).toContain('Calendar permission denied'); + }); + + it('throws on invalid date format', async () => { + const result = await executeToolCall({ + id: 'cal_3', + name: 'create_calendar_event', + arguments: { title: 'Meeting', start_date: 'not-a-date', end_date: '2025-01-01T11:00:00Z' }, + }); + expect(result.error).toContain('Invalid date format'); + }); + + it('creates event and returns success message', async () => { + const result = await executeToolCall({ + id: 'cal_4', + name: 'create_calendar_event', + arguments: { title: 'Meeting', start_date: '2025-01-01T10:00:00Z', end_date: '2025-01-01T11:00:00Z' }, + }); + expect(result.error).toBeUndefined(); + expect(result.content).toContain('Meeting'); + }); + + it('includes location suffix when location is provided', async () => { + const result = await executeToolCall({ + id: 'cal_5', + name: 'create_calendar_event', + arguments: { title: 'Standup', start_date: '2025-01-01T09:00:00Z', end_date: '2025-01-01T09:30:00Z', location: 'Room 4' }, + }); + expect(result.content).toContain('at Room 4'); + expect(RNCalendarEvents.saveEvent).toHaveBeenCalledWith('Standup', expect.objectContaining({ location: 'Room 4' })); + }); + + it('passes notes to saveEvent when provided', async () => { + const result = await executeToolCall({ + id: 'cal_6', + name: 'create_calendar_event', + arguments: { title: 'Review', start_date: '2025-01-02T14:00:00Z', end_date: '2025-01-02T15:00:00Z', notes: 'Bring slides' }, + }); + expect(result.error).toBeUndefined(); + expect(RNCalendarEvents.saveEvent).toHaveBeenCalledWith('Review', expect.objectContaining({ notes: 'Bring slides' })); + }); + }); + + describe('read_calendar_events', () => { + it('returns no-events message when calendar is empty', async () => { + RNCalendarEvents.fetchAllEvents.mockResolvedValue([]); + const result = await executeToolCall({ + id: 'cal_7', + name: 'read_calendar_events', + arguments: { start_date: '2025-01-01T00:00:00Z', end_date: '2025-01-07T00:00:00Z' }, + }); + expect(result.content).toContain('No calendar events found'); + }); + + it('uses current date when no start_date provided', async () => { + RNCalendarEvents.fetchAllEvents.mockResolvedValue([]); + const result = await executeToolCall({ + id: 'cal_8', + name: 'read_calendar_events', + arguments: {}, + }); + expect(result.error).toBeUndefined(); + expect(RNCalendarEvents.fetchAllEvents).toHaveBeenCalledTimes(1); + }); + + it('throws on invalid start date', async () => { + const result = await executeToolCall({ + id: 'cal_9', + name: 'read_calendar_events', + arguments: { start_date: 'bad-date' }, + }); + expect(result.error).toContain('Invalid start date'); + }); + + it('throws on invalid end date', async () => { + const result = await executeToolCall({ + id: 'cal_10', + name: 'read_calendar_events', + arguments: { start_date: '2025-01-01T00:00:00Z', end_date: 'bad-date' }, + }); + expect(result.error).toContain('Invalid end date'); + }); + + it('formats events with location and notes', async () => { + RNCalendarEvents.fetchAllEvents.mockResolvedValue([ + { title: 'Sprint Review', startDate: '2025-01-03T10:00:00Z', endDate: '2025-01-03T11:00:00Z', location: 'Conf Room', notes: 'Bring laptop' }, + ]); + const result = await executeToolCall({ + id: 'cal_11', + name: 'read_calendar_events', + arguments: { start_date: '2025-01-01T00:00:00Z', end_date: '2025-01-07T00:00:00Z' }, + }); + expect(result.content).toContain('Sprint Review'); + expect(result.content).toContain('Location: Conf Room'); + expect(result.content).toContain('Notes: Bring laptop'); + }); + + it('formats events without location and notes', async () => { + RNCalendarEvents.fetchAllEvents.mockResolvedValue([ + { title: 'Daily Standup', startDate: '2025-01-03T09:00:00Z', endDate: null }, + ]); + const result = await executeToolCall({ + id: 'cal_12', + name: 'read_calendar_events', + arguments: { start_date: '2025-01-01T00:00:00Z', end_date: '2025-01-07T00:00:00Z' }, + }); + expect(result.content).toContain('Daily Standup'); + expect(result.content).toContain('unknown'); + expect(result.content).not.toContain('Location:'); + }); + }); +}); + +describe('web_search handler', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns error for missing query parameter', async () => { + const result = await executeToolCall({ id: 'ws_1', name: 'web_search', arguments: {} }); + expect(result.error).toContain('Missing required parameter: query'); + }); + + it('returns no-results message when search returns empty HTML', async () => { + mockFetch.mockResolvedValue({ text: async () => 'Nothing here' }); + const result = await executeToolCall({ id: 'ws_2', name: 'web_search', arguments: { query: 'xyzzy' } }); + expect(result.error).toBeUndefined(); + expect(result.content).toContain('No results found'); + }); + + it('returns formatted results when search finds matches', async () => { + const html = [ + '
', + 'Click me', + 'Example Title', + '

This is a snippet about the result

', + '
', + ].join(''); + mockFetch.mockResolvedValue({ text: async () => html }); + const result = await executeToolCall({ id: 'ws_3', name: 'web_search', arguments: { query: 'example' } }); + expect(result.error).toBeUndefined(); + expect(result.content).toBeDefined(); + }); +}); + +describe('send_email handler', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOpenURL.mockResolvedValue(undefined); + }); + + it('opens mail app with to, subject, and body', async () => { + const result = await executeToolCall({ + id: 'se_1', + name: 'send_email', + arguments: { to: 'test@example.com', subject: 'Hello', body: 'World' }, + }); + expect(result.error).toBeUndefined(); + expect(result.content).toContain('test@example.com'); + expect(result.content).toContain('Hello'); + expect(mockOpenURL).toHaveBeenCalledWith(expect.stringContaining('mailto:')); + }); + + it('opens mail app with only the to address when no subject or body', async () => { + const result = await executeToolCall({ + id: 'se_2', + name: 'send_email', + arguments: { to: 'user@example.com' }, + }); + expect(result.error).toBeUndefined(); + expect(result.content).toContain('user@example.com'); + expect(mockOpenURL).toHaveBeenCalledWith(expect.stringContaining('mailto:')); + }); + + it('returns error when mail app cannot be opened', async () => { + mockOpenURL.mockRejectedValue(new Error('No mail app')); + const result = await executeToolCall({ + id: 'se_3', + name: 'send_email', + arguments: { to: 'fail@example.com' }, + }); + expect(result.error).toContain('mail app'); + }); + + it('returns error for missing to parameter', async () => { + const result = await executeToolCall({ id: 'se_4', name: 'send_email', arguments: {} }); + expect(result.error).toContain('Missing required parameter: to'); + }); +}); diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts index ceb41b36..d8bd1c26 100644 --- a/src/services/proLicenseService.ts +++ b/src/services/proLicenseService.ts @@ -12,6 +12,11 @@ const RC_API_KEY_ANDROID = 'test_UDUmOVwoEWFUtYONRUfQOOjVisB'; type ProLicense = { isPro: boolean; verifiedAt: number }; +function setProInStore(isPro: boolean): void { + const { useAppStore } = require('../stores/appStore'); + useAppStore.getState().setHasRegisteredPro(isPro); +} + export function configureRevenueCat(): void { Purchases.setLogLevel(__DEV__ ? LOG_LEVEL.DEBUG : LOG_LEVEL.ERROR); Purchases.configure({ @@ -49,8 +54,7 @@ async function syncWithRevenueCat(): Promise { const info = await Purchases.getCustomerInfo(); const isPro = info.entitlements.active[ENTITLEMENT_ID] !== undefined; await writeLicense(isPro); - const { useAppStore } = require('../stores/appStore'); - useAppStore.getState().setHasRegisteredPro(isPro); + setProInStore(isPro); } catch { // No network — cached value stands } @@ -62,8 +66,7 @@ export async function presentProPaywall(): Promise { case PAYWALL_RESULT.PURCHASED: case PAYWALL_RESULT.RESTORED: { await writeLicense(true); - const { useAppStore } = require('../stores/appStore'); - useAppStore.getState().setHasRegisteredPro(true); + setProInStore(true); return true; } default: @@ -75,13 +78,11 @@ export async function restorePro(): Promise { const info = await Purchases.restorePurchases(); const isPro = info.entitlements.active[ENTITLEMENT_ID] !== undefined; await writeLicense(isPro); - const { useAppStore } = require('../stores/appStore'); - useAppStore.getState().setHasRegisteredPro(isPro); + setProInStore(isPro); return isPro; } export async function clearProForTesting(): Promise { await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE }); - const { useAppStore } = require('../stores/appStore'); - useAppStore.getState().setHasRegisteredPro(false); + setProInStore(false); } diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts index ccdc17f4..297bc0e5 100644 --- a/src/services/tools/handlers.ts +++ b/src/services/tools/handlers.ts @@ -430,21 +430,25 @@ async function handleSendEmail(to: string, subject?: string, body?: string): Pro return `Mail app opened with a draft to ${to}${subjectSuffix}.`; } +async function ensureCalendarPermission(readonly: boolean): Promise { + if (!(RNCalendarEvents as any)?.requestPermissions) { + throw new Error('Calendar package not available. Rebuild the app to use this tool.'); + } + const status = await RNCalendarEvents.requestPermissions(readonly); + if (status !== 'authorized') throw new Error('Calendar permission denied'); +} + async function handleCreateCalendarEvent( event: { title: string; startDate: string; endDate: string; location?: string; notes?: string }, ): Promise { const { title, startDate, endDate, location, notes } = event; - if (!(RNCalendarEvents as any)?.saveEvent) { - throw new Error('Calendar package not available. Rebuild the app to use this tool.'); - } const start = new Date(startDate); const end = new Date(endDate); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { throw new TypeError('Invalid date format. Please provide valid ISO 8601 dates.'); } // requestPermissions(false) asks for read/write on Android; iOS is always read/write. - const status = await RNCalendarEvents.requestPermissions(false); - if (status !== 'authorized') throw new Error('Calendar permission denied'); + await ensureCalendarPermission(false); await RNCalendarEvents.saveEvent(title, { startDate: start.toISOString(), endDate: end.toISOString(), @@ -457,11 +461,7 @@ async function handleCreateCalendarEvent( } async function handleReadCalendarEvents(startDateStr?: string, endDateStr?: string): Promise { - if (!(RNCalendarEvents as any)?.requestPermissions) { - throw new Error('Calendar package not available. Rebuild the app to use this tool.'); - } - const status = await RNCalendarEvents.requestPermissions(true); - if (status !== 'authorized') throw new Error('Calendar permission denied'); + await ensureCalendarPermission(true); const startDt = startDateStr ? new Date(startDateStr) : new Date(); if (Number.isNaN(startDt.getTime())) { throw new TypeError('Invalid start date format.'); From 5af30ea19ad5f59b0c24987063c864e9a0988c78 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 18 Jun 2026 12:57:02 +0530 Subject: [PATCH 24/28] feat: wire RevenueCat pro license service with cross-platform offering - Configure RevenueCat at boot and gate the `pro` entitlement before loading pro features; cache the entitlement in the keychain and sync in the background. - Purchase directly via offerings.current so one Lifetime package serves App Store, Play Store, and Web products. - Externalize public SDK keys to src/config/revenueCatKeys.ts (placeholders committed; real keys kept local-only). - Add a global react-native-purchases test mock plus unit and integration tests for the configure -> check -> purchase -> entitlement flow. Co-Authored-By: Dishit Karia --- App.tsx | 11 +- .../onboarding/proBootFlow.test.ts | 112 +++++++++++++ .../unit/services/loadProFeatures.test.ts | 17 +- .../unit/services/proLicenseService.test.ts | 57 ++++--- ios/Podfile.lock | 38 ++++- jest.setup.ts | 18 +++ package-lock.json | 69 ++++++++ package.json | 2 + src/bootstrap/loadProFeatures.ts | 8 +- src/config/revenueCatKeys.ts | 25 +++ src/screens/ProDetailScreen/index.tsx | 152 ++++++++++++++++-- src/services/proLicenseService.ts | 139 +++++++++++++--- 12 files changed, 580 insertions(+), 68 deletions(-) create mode 100644 __tests__/integration/onboarding/proBootFlow.test.ts create mode 100644 src/config/revenueCatKeys.ts diff --git a/App.tsx b/App.tsx index d66d8827..3c544dec 100644 --- a/App.tsx +++ b/App.tsx @@ -16,6 +16,7 @@ import logger, { setLogListener } from './src/utils/logger'; import { useAppStore, useAuthStore, useRemoteServerStore } from './src/stores'; import { useDebugLogsStore } from './src/stores/debugLogsStore'; import { loadProFeatures } from './src/bootstrap/loadProFeatures'; +import { configureRevenueCat, checkProStatus } from './src/services/proLicenseService'; import { hydrateDownloadStore } from './src/services/downloadHydration'; import { useDownloadListeners } from './src/hooks/useDownloads'; import { LockScreen } from './src/screens'; @@ -171,8 +172,14 @@ function App() { // Initialize RAG database tables ragService.ensureReady().catch((err) => logger.error('Failed to initialize RAG service on startup', err)); - // Load pro features synchronously (screens registered before AppNavigator renders) - loadProFeatures().catch((err) => logger.error('[App] loadProFeatures failed:', err)); + // Configure RevenueCat and read the cached entitlement before Pro features load. + // configureRevenueCat is sync; checkProStatus reads the keychain cache immediately + // and fires a background RC network sync so the next launch stays fresh. + configureRevenueCat(); + await checkProStatus(); + + // Load pro features — only activates if the keychain entitlement is set. + await loadProFeatures(); // Show the UI immediately setIsInitializing(false); diff --git a/__tests__/integration/onboarding/proBootFlow.test.ts b/__tests__/integration/onboarding/proBootFlow.test.ts new file mode 100644 index 00000000..58e3c2c9 --- /dev/null +++ b/__tests__/integration/onboarding/proBootFlow.test.ts @@ -0,0 +1,112 @@ +/** + * Integration: Pro boot flow + * + * Verifies that configureRevenueCat + checkProStatus run before loadProFeatures, + * and that Pro features only activate when the keychain entitlement is set. + */ + +jest.mock('react-native-purchases', () => ({ + __esModule: true, + default: { + setLogLevel: jest.fn(), + configure: jest.fn(), + getCustomerInfo: jest.fn().mockResolvedValue({ entitlements: { active: {} }, originalAppUserId: 'anon', allPurchaseDates: {} }), + invalidateCustomerInfoCache: jest.fn().mockResolvedValue(undefined), + getOfferings: jest.fn(), + purchasePackage: jest.fn(), + restorePurchases: jest.fn(), + logOut: jest.fn(), + }, + LOG_LEVEL: { DEBUG: 'debug', ERROR: 'error' }, +})); + +jest.mock('react-native-keychain', () => ({ + getGenericPassword: jest.fn(), + setGenericPassword: jest.fn(), + resetGenericPassword: jest.fn(), + ACCESSIBLE: { AFTER_FIRST_UNLOCK: 'AfterFirstUnlock' }, +})); + +jest.mock('../../../src/stores/appStore', () => { + const setHasRegisteredPro = jest.fn(); + return { useAppStore: { getState: () => ({ setHasRegisteredPro }) } }; +}); + +jest.mock('../../../src/services/tools/extensions', () => ({ registerToolExtension: jest.fn() })); +jest.mock('../../../src/navigation/screenRegistry', () => ({ registerScreen: jest.fn() })); +jest.mock('../../../src/components/settings/sectionRegistry', () => ({ registerSettingsSection: jest.fn() })); +jest.mock('@offgrid/pro', () => ({ activate: jest.fn() }), { virtual: true }); + +import { configureRevenueCat, checkProStatus } from '../../../src/services/proLicenseService'; +import { loadProFeatures } from '../../../src/bootstrap/loadProFeatures'; + +const Purchases = require('react-native-purchases').default; +const Keychain = require('react-native-keychain'); +const mockConfigure = Purchases.configure; +const mockGetCustomerInfo = Purchases.getCustomerInfo; +const mockGetGenericPassword = Keychain.getGenericPassword; +const mockSetGenericPassword = Keychain.setGenericPassword; +const mockActivate = require('@offgrid/pro').activate; +const mockSetHasRegisteredPro = require('../../../src/stores/appStore').useAppStore.getState().setHasRegisteredPro; + +describe('Pro boot flow integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('configures RevenueCat, reads entitlement, and skips Pro activation when not subscribed', async () => { + mockGetGenericPassword.mockResolvedValue(false); + mockGetCustomerInfo.mockResolvedValue({ entitlements: { active: {} } }); + + configureRevenueCat(); + await checkProStatus(); + await loadProFeatures(); + + expect(mockConfigure).toHaveBeenCalledTimes(1); + expect(mockActivate).not.toHaveBeenCalled(); + }); + + it('configures RevenueCat, reads entitlement, and activates Pro when subscribed', async () => { + const license = JSON.stringify({ isPro: true, verifiedAt: 0 }); + mockGetGenericPassword.mockResolvedValue({ password: license }); + mockGetCustomerInfo.mockResolvedValue({ + entitlements: { active: { pro: { productIdentifier: 'offgrid_pro' } } }, + }); + + configureRevenueCat(); + await checkProStatus(); + await loadProFeatures(); + + expect(mockConfigure).toHaveBeenCalledTimes(1); + expect(mockActivate).toHaveBeenCalledWith( + expect.objectContaining({ + registerToolExtension: expect.any(Function), + registerScreen: expect.any(Function), + registerSettingsSection: expect.any(Function), + }), + ); + }); + + it('background RC sync writes updated entitlement to store after boot', async () => { + // Keychain is empty but RC says the user is subscribed (e.g. new device install) + mockGetGenericPassword + .mockResolvedValueOnce(false) // first read in checkProStatus (returns cached false) + .mockResolvedValue({ password: JSON.stringify({ isPro: true, verifiedAt: 1 }) }); + mockGetCustomerInfo.mockResolvedValue({ + entitlements: { active: { pro: { productIdentifier: 'offgrid_pro' } } }, + }); + mockSetGenericPassword.mockResolvedValue(true); + + configureRevenueCat(); + const isPro = await checkProStatus(); + + // Cached value from empty keychain is false; background sync fires async + expect(isPro).toBe(false); + + // Allow the background syncWithRevenueCat to complete + await new Promise(resolve => setImmediate(resolve)); + + expect(mockSetGenericPassword).toHaveBeenCalledTimes(1); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); + }); +}); diff --git a/__tests__/unit/services/loadProFeatures.test.ts b/__tests__/unit/services/loadProFeatures.test.ts index a0cfde15..56e0b4f3 100644 --- a/__tests__/unit/services/loadProFeatures.test.ts +++ b/__tests__/unit/services/loadProFeatures.test.ts @@ -10,9 +10,15 @@ jest.mock('../../../src/components/settings/sectionRegistry', () => ({ registerSettingsSection: jest.fn(), })); +const mockReadProFromKeychain = jest.fn(); +jest.mock('../../../src/services/proLicenseService', () => ({ + readProFromKeychain: (...args: any[]) => mockReadProFromKeychain(...args), +})); + describe('loadProFeatures()', () => { beforeEach(() => { jest.resetModules(); + mockReadProFromKeychain.mockResolvedValue(false); }); it('returns without error when @offgrid/pro package is not installed', async () => { @@ -25,9 +31,18 @@ describe('loadProFeatures()', () => { await expect(loadProFeatures()).resolves.toBeUndefined(); }); - it('calls pro.activate with the three registries when package is present', async () => { + it('does not call pro.activate when there is no entitlement', async () => { + const mockActivate = jest.fn(); + jest.mock('@offgrid/pro', () => ({ activate: mockActivate }), { virtual: true }); + mockReadProFromKeychain.mockResolvedValueOnce(false); + await loadProFeatures(); + expect(mockActivate).not.toHaveBeenCalled(); + }); + + it('calls pro.activate with the three registries when entitlement is active', async () => { const mockActivate = jest.fn(); jest.mock('@offgrid/pro', () => ({ activate: mockActivate }), { virtual: true }); + mockReadProFromKeychain.mockResolvedValueOnce(true); await loadProFeatures(); expect(mockActivate).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/__tests__/unit/services/proLicenseService.test.ts b/__tests__/unit/services/proLicenseService.test.ts index 2d6c7c33..676e49c9 100644 --- a/__tests__/unit/services/proLicenseService.test.ts +++ b/__tests__/unit/services/proLicenseService.test.ts @@ -9,15 +9,18 @@ import { jest.mock('react-native-purchases', () => ({ __esModule: true, - default: { setLogLevel: jest.fn(), configure: jest.fn(), getCustomerInfo: jest.fn(), restorePurchases: jest.fn() }, + default: { + setLogLevel: jest.fn(), + configure: jest.fn(), + getCustomerInfo: jest.fn().mockResolvedValue({ entitlements: { active: {} }, originalAppUserId: 'anon', allPurchaseDates: {} }), + restorePurchases: jest.fn(), + getOfferings: jest.fn(), + purchasePackage: jest.fn(), + invalidateCustomerInfoCache: jest.fn().mockResolvedValue(undefined), + logOut: jest.fn(), + }, LOG_LEVEL: { DEBUG: 'debug', ERROR: 'error' }, -}), { virtual: true }); - -jest.mock('react-native-purchases-ui', () => ({ - __esModule: true, - default: { presentPaywall: jest.fn() }, - PAYWALL_RESULT: { PURCHASED: 'PURCHASED', RESTORED: 'RESTORED', NOT_PRESENTED: 'NOT_PRESENTED', ERROR: 'ERROR', CANCELLED: 'CANCELLED' }, -}), { virtual: true }); +})); jest.mock('react-native-keychain', () => ({ getGenericPassword: jest.fn(), @@ -34,10 +37,18 @@ jest.mock('../../../src/stores/appStore', () => ({ const { getGenericPassword: mockGetGenericPassword, setGenericPassword: mockSetGenericPassword, resetGenericPassword: mockResetGenericPassword } = require('react-native-keychain'); const Purchases = require('react-native-purchases').default; -const RevenueCatUI = require('react-native-purchases-ui').default; -const { PAYWALL_RESULT } = require('react-native-purchases-ui'); -const ENTITLEMENT_ACTIVE = { 'offgrid Pro': { productIdentifier: 'pro_monthly' } }; +const makeOffering = () => ({ + all: {}, + current: { + identifier: 'default', + availablePackages: [ + { identifier: '$rc_lifetime', product: { identifier: 'off_grid_pro_lifetime', priceString: '$9.99' } }, + ], + }, +}); + +const ENTITLEMENT_ACTIVE = { pro: { productIdentifier: 'pro_monthly' } }; const ENTITLEMENT_EMPTY = {}; describe('proLicenseService', () => { @@ -82,22 +93,28 @@ describe('proLicenseService', () => { }); describe('presentProPaywall()', () => { - it('returns true and writes license when PURCHASED', async () => { - RevenueCatUI.presentPaywall.mockResolvedValueOnce(PAYWALL_RESULT.PURCHASED); + it('returns true and writes license when the purchase grants the entitlement', async () => { + Purchases.getOfferings.mockResolvedValueOnce(makeOffering()); + Purchases.purchasePackage.mockResolvedValueOnce({ + customerInfo: { entitlements: { active: ENTITLEMENT_ACTIVE }, originalAppUserId: 'anon' }, + }); mockSetGenericPassword.mockResolvedValueOnce(true); expect(await presentProPaywall()).toBe(true); expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); }); - it('returns true and writes license when RESTORED', async () => { - RevenueCatUI.presentPaywall.mockResolvedValueOnce(PAYWALL_RESULT.RESTORED); - mockSetGenericPassword.mockResolvedValueOnce(true); - expect(await presentProPaywall()).toBe(true); - expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); + it('returns false when the purchase does not grant the entitlement', async () => { + Purchases.getOfferings.mockResolvedValueOnce(makeOffering()); + Purchases.purchasePackage.mockResolvedValueOnce({ + customerInfo: { entitlements: { active: ENTITLEMENT_EMPTY }, originalAppUserId: 'anon' }, + }); + expect(await presentProPaywall()).toBe(false); + expect(mockSetHasRegisteredPro).not.toHaveBeenCalled(); }); - it('returns false when CANCELLED', async () => { - RevenueCatUI.presentPaywall.mockResolvedValueOnce(PAYWALL_RESULT.CANCELLED); + it('returns false when the user cancels', async () => { + Purchases.getOfferings.mockResolvedValueOnce(makeOffering()); + Purchases.purchasePackage.mockRejectedValueOnce({ userCancelled: true }); expect(await presentProPaywall()).toBe(false); expect(mockSetHasRegisteredPro).not.toHaveBeenCalled(); }); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dc97e1c8..67bd0783 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -98,6 +98,11 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - PurchasesHybridCommon (18.14.1): + - RevenueCat (= 5.78.0) + - PurchasesHybridCommonUI (18.14.1): + - PurchasesHybridCommon (= 18.14.1) + - RevenueCatUI (= 5.78.0) - RCT-Folly (2024.11.18.00): - boost - DoubleConversion @@ -1982,8 +1987,6 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - react-native-add-calendar-event (5.0.0): - - React-Core - react-native-blur (4.4.1): - boost - DoubleConversion @@ -2799,6 +2802,9 @@ PODS: - React-perflogger (= 0.83.1) - React-utils (= 0.83.1) - SocketRocket + - RevenueCat (5.78.0) + - RevenueCatUI (5.78.0): + - RevenueCat (= 5.78.0) - RNCalendarEvents (2.2.0): - React - RNCAsyncStorage (2.2.0): @@ -2889,6 +2895,12 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - RNPaywalls (10.3.0): + - PurchasesHybridCommonUI (= 18.14.1) + - React-Core + - RNPurchases (10.3.0): + - PurchasesHybridCommon (= 18.14.1) + - React-Core - RNReactNativeHapticFeedback (2.3.3): - boost - DoubleConversion @@ -3332,7 +3344,6 @@ DEPENDENCIES: - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - - react-native-add-calendar-event (from `../node_modules/react-native-add-calendar-event`) - "react-native-blur (from `../node_modules/@react-native-community/blur`)" - "react-native-document-picker (from `../node_modules/@react-native-documents/picker`)" - "react-native-document-viewer (from `../node_modules/@react-native-documents/viewer`)" @@ -3379,6 +3390,8 @@ DEPENDENCIES: - RNFS (from `../node_modules/react-native-fs`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNKeychain (from `../node_modules/react-native-keychain`) + - RNPaywalls (from `../node_modules/react-native-purchases-ui`) + - RNPurchases (from `../node_modules/react-native-purchases`) - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) @@ -3393,6 +3406,10 @@ DEPENDENCIES: SPEC REPOS: trunk: - lottie-ios + - PurchasesHybridCommon + - PurchasesHybridCommonUI + - RevenueCat + - RevenueCatUI - SocketRocket - SSZipArchive @@ -3492,8 +3509,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" React-microtasksnativemodule: :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" - react-native-add-calendar-event: - :path: "../node_modules/react-native-add-calendar-event" react-native-blur: :path: "../node_modules/@react-native-community/blur" react-native-document-picker: @@ -3586,6 +3601,10 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-gesture-handler" RNKeychain: :path: "../node_modules/react-native-keychain" + RNPaywalls: + :path: "../node_modules/react-native-purchases-ui" + RNPurchases: + :path: "../node_modules/react-native-purchases" RNReactNativeHapticFeedback: :path: "../node_modules/react-native-haptic-feedback" RNReanimated: @@ -3618,6 +3637,8 @@ SPEC CHECKSUMS: lottie-ios: a881093fab623c467d3bce374367755c272bdd59 lottie-react-native: 691b8363e8c591fb78a78254ff2517258891456b op-sqlite: bafff369cecaee4fe65c89eec47deaba26f2db95 + PurchasesHybridCommon: 825e4e748b62c919bc4cb4032b0d1e452409bd74 + PurchasesHybridCommonUI: 536abdfc64b82adcbb9ba352630722a4a1270571 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: a41bbdd9af30bf2e5715796b313e44ec43eefff1 RCTRequired: 7be34aabb0b77c3cefe644528df0fa0afad4e4d0 @@ -3654,7 +3675,6 @@ SPEC CHECKSUMS: React-logger: b8483fa08e0d62e430c76d864309d90576ca2f68 React-Mapbuffer: 7b72a669e94662359dad4f42b5af005eb24b4e83 React-microtasksnativemodule: cdc02da075f2857803ed63f24f5f72fc40e094c0 - react-native-add-calendar-event: 4eb42bdf84beb58de81f6d2ce1778a7632223dfe react-native-blur: 6af83e7e3c4c1446a188d9b2c493600fc4beb173 react-native-document-picker: dc2d83366e47e89e7c51e8a41eab99c1d54e941c react-native-document-viewer: 8c6ed07e7e27352743fa98e8dd6d288ad925b884 @@ -3695,12 +3715,16 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 0eb286cc274abb059ee601b862ebddac2e681d01 ReactCodegen: 3d48510bcef445f6403c0004047d4d9cbb915435 ReactCommon: ac934cb340aee91282ecd6f273a26d24d4c55cae + RevenueCat: f3641992451c1426da8d8d1466cec3bbdb765962 + RevenueCatUI: 32998f100fe73f1d0172df7eef4f0dd1bc781a05 RNCalendarEvents: f90f73666b6bcbb3cc8a491ffbb5e48c0db3de37 RNCAsyncStorage: 29f0230e1a25f36c20b05f65e2eb8958d6526e82 RNDeviceInfo: 36d7f232bfe7c9b5c494cb7793230424ed32c388 RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8 RNGestureHandler: cd4be101cfa17ea6bbd438710caa02e286a84381 RNKeychain: a2c134ab796272c3d605e035ab727591000b30f3 + RNPaywalls: f125e0b623a8ccadbe45e2815ac024bbf9053bb9 + RNPurchases: ebcfb4effc608048bf2481a413b0bcaa2b6677f7 RNReactNativeHapticFeedback: be4f1b4bf0398c30b59b76ed92ecb0a2ff3a69c6 RNReanimated: 292cd58688552a22b3fc1cefcfbc49b336dfed68 RNScreens: 714e10b6b554f7dc7ad9f78dcf36dc8e3fc73415 @@ -3713,6 +3737,6 @@ SPEC CHECKSUMS: whisper-rn: 7566faf9b7d78e39ab9fc634cb90fdee81177793 Yoga: 5456bb010373068fc92221140921b09d126b116e -PODFILE CHECKSUM: 30f084aba7ca9595d2440626582f30bcd67c6464 +PODFILE CHECKSUM: 88f0d247acf3f66fc1ac0eb9612471f79e7f0cce COCOAPODS: 1.16.2 diff --git a/jest.setup.ts b/jest.setup.ts index 15d0f8cb..b007647d 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -216,6 +216,24 @@ jest.mock('react-native-keychain', () => ({ resetGenericPassword: jest.fn(() => Promise.resolve(true)), })); +// react-native-purchases mock — the real package loads a native module that +// is unavailable in the node test env, so any (even transitive / coverage-only) +// require would throw. Suites that need behaviour override this with a local mock. +jest.mock('react-native-purchases', () => ({ + __esModule: true, + default: { + setLogLevel: jest.fn(), + configure: jest.fn(), + getCustomerInfo: jest.fn(() => Promise.resolve({ entitlements: { active: {} }, originalAppUserId: 'anon', allPurchaseDates: {} })), + restorePurchases: jest.fn(() => Promise.resolve({ entitlements: { active: {} } })), + getOfferings: jest.fn(() => Promise.resolve({ all: {}, current: null })), + purchasePackage: jest.fn(() => Promise.resolve({ customerInfo: { entitlements: { active: {} } } })), + invalidateCustomerInfoCache: jest.fn(() => Promise.resolve()), + logOut: jest.fn(() => Promise.resolve()), + }, + LOG_LEVEL: { DEBUG: 'debug', ERROR: 'error' }, +})); + // @react-native-voice/voice mock jest.mock('@react-native-voice/voice', () => ({ start: jest.fn(() => Promise.resolve()), diff --git a/package-lock.json b/package-lock.json index 229c1e61..8ea212dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,8 @@ "react-native-image-picker": "^8.2.1", "react-native-keychain": "^10.0.0", "react-native-linear-gradient": "^2.8.3", + "react-native-purchases": "^10.3.0", + "react-native-purchases-ui": "^10.3.0", "react-native-reanimated": "^4.2.1", "react-native-safe-area-context": "^5.6.2", "react-native-screens": "^4.20.0", @@ -4055,6 +4057,27 @@ "nanoid": "^3.3.11" } }, + "node_modules/@revenuecat/purchases-js": { + "version": "1.42.3", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-js/-/purchases-js-1.42.3.tgz", + "integrity": "sha512-CkR+Jj0QtsO+SO1AN7stXwJsWGNO33NJEPXkvk//v1ppB0Qff1B3y+022rCo27tJiQHFllR0K78czcUKBrizvg==", + "license": "MIT" + }, + "node_modules/@revenuecat/purchases-js-hybrid-mappings": { + "version": "18.14.1", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-js-hybrid-mappings/-/purchases-js-hybrid-mappings-18.14.1.tgz", + "integrity": "sha512-O5U3h7qsvGFCUG1C4Kmb/CgGwK/zSF5NDXVu2v1AMTlKxXdDU3hpDyC/lesfmSpW8x4qIPeQU+p9wLMY8V68Bg==", + "license": "MIT", + "dependencies": { + "@revenuecat/purchases-js": "1.42.3" + } + }, + "node_modules/@revenuecat/purchases-typescript-internal": { + "version": "18.14.1", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal/-/purchases-typescript-internal-18.14.1.tgz", + "integrity": "sha512-A4dn2jxmSVWO7LbjGxHT/tNFH3INKCoM0utu9fPzTtqdi8hd61mPrjBAzBLMCHal4KzCzp8MKRguNCedzLP7qA==", + "license": "MIT" + }, "node_modules/@ronradtke/react-native-markdown-display": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@ronradtke/react-native-markdown-display/-/react-native-markdown-display-8.1.0.tgz", @@ -12452,6 +12475,52 @@ "react-native": "*" } }, + "node_modules/react-native-purchases": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/react-native-purchases/-/react-native-purchases-10.3.0.tgz", + "integrity": "sha512-PTxp+MbGIIeZomcTcUGiZLw6IzoSd4Vmfpw0Htdxk5axPBS4O+sRXtgScI6rsEe1EwKSqBHSdi4LPQhZgZeAgQ==", + "license": "MIT", + "workspaces": [ + "examples/purchaseTesterTypescript", + "react-native-purchases-store-galaxy", + "react-native-purchases-ui", + "e2e-tests/MaestroTestApp" + ], + "dependencies": { + "@revenuecat/purchases-js-hybrid-mappings": "18.14.1", + "@revenuecat/purchases-typescript-internal": "18.14.1" + }, + "peerDependencies": { + "react": ">= 16.6.3", + "react-native": ">= 0.73.0", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/react-native-purchases-ui": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/react-native-purchases-ui/-/react-native-purchases-ui-10.3.0.tgz", + "integrity": "sha512-tMumwPy/tJsdoYdPIDgMiGiGxUSzEevJfW+DGtFX/207PeB5tLF/CQG8/tw+34A+lCZ51JLrMoourEDCDgJvMA==", + "license": "MIT", + "dependencies": { + "@revenuecat/purchases-typescript-internal": "18.14.1" + }, + "peerDependencies": { + "react": "*", + "react-native": ">= 0.73.0", + "react-native-purchases": "10.3.0", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/react-native-reanimated": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz", diff --git a/package.json b/package.json index 2d3bcd85..9b02d5b9 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,8 @@ "react-native-image-picker": "^8.2.1", "react-native-keychain": "^10.0.0", "react-native-linear-gradient": "^2.8.3", + "react-native-purchases": "^10.3.0", + "react-native-purchases-ui": "^10.3.0", "react-native-reanimated": "^4.2.1", "react-native-safe-area-context": "^5.6.2", "react-native-screens": "^4.20.0", diff --git a/src/bootstrap/loadProFeatures.ts b/src/bootstrap/loadProFeatures.ts index ba7c719f..016c8b9b 100644 --- a/src/bootstrap/loadProFeatures.ts +++ b/src/bootstrap/loadProFeatures.ts @@ -1,6 +1,7 @@ import { registerToolExtension } from '../services/tools/extensions'; import { registerScreen } from '../navigation/screenRegistry'; import { registerSettingsSection } from '../components/settings/sectionRegistry'; +import { readProFromKeychain } from '../services/proLicenseService'; export async function loadProFeatures(): Promise { let pro: any; @@ -13,7 +14,10 @@ export async function loadProFeatures(): Promise { return; // proStub.js returns null — free build via metro extraNodeModules } - // Run synchronously before any await so screens are registered before the - // navigator renders (App.tsx doesn't await loadProFeatures). + const isPro = await readProFromKeychain(); + if (!isPro) { + return; // paid features stay dormant until the user purchases + } + pro.activate({ registerToolExtension, registerScreen, registerSettingsSection }); } diff --git a/src/config/revenueCatKeys.ts b/src/config/revenueCatKeys.ts new file mode 100644 index 00000000..a78d6b0e --- /dev/null +++ b/src/config/revenueCatKeys.ts @@ -0,0 +1,25 @@ +/** + * RevenueCat public SDK keys. + * + * The production iOS/Android keys are intentionally NOT committed. Put the real keys in + * your local working copy, then keep them out of git with: + * + * git update-index --skip-worktree src/config/revenueCatKeys.ts + * + * (RevenueCat public SDK keys are not secret — they're extractable from any app binary — + * but we keep the production keys out of the open-source repo as a hygiene measure.) + * + * Get the keys from the RevenueCat dashboard: + * - iOS key starts with 'appl_' + * - Android key starts with 'goog_' + */ +export const RC_API_KEY_IOS = 'appl_REPLACE_WITH_IOS_KEY'; +export const RC_API_KEY_ANDROID = 'goog_REPLACE_WITH_ANDROID_KEY'; + +/** + * RevenueCat Test Store key — public, safe to commit. Routes purchases through RC's + * simulated store (no App Store / Play Store needed) for local flow testing. Only used + * when USE_RC_TEST_STORE is true AND the build is __DEV__, so it can never reach production. + */ +export const RC_API_KEY_TEST_STORE = 'test_UDUmOVwoEWFUtYONRUfQOOjVisB'; +export const USE_RC_TEST_STORE = false; diff --git a/src/screens/ProDetailScreen/index.tsx b/src/screens/ProDetailScreen/index.tsx index b3e06444..1600f169 100644 --- a/src/screens/ProDetailScreen/index.tsx +++ b/src/screens/ProDetailScreen/index.tsx @@ -1,13 +1,13 @@ -import React from 'react'; -import { View, Text, TouchableOpacity, ScrollView, Linking, StyleSheet } from 'react-native'; +import React, { useState } from 'react'; +import { View, Text, TouchableOpacity, ScrollView, Alert, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import Icon from 'react-native-vector-icons/Feather'; import LinearGradient from 'react-native-linear-gradient'; import { useTheme, useThemedStyles } from '../../theme'; import type { ThemeColors, ThemeShadows } from '../../theme'; import { SPACING, TYPOGRAPHY } from '../../constants'; -import { PRO_URL } from '../../utils/proPrompt'; import { useAppStore } from '../../stores'; +import { presentProPaywall, restorePro, resetProIdentityForTesting } from '../../services/proLicenseService'; const INTEGRATIONS = [ { icon: 'mic', title: 'Voice', desc: 'Local speech-to-text\nprocessing.' }, @@ -16,15 +16,49 @@ const INTEGRATIONS = [ { icon: 'message-square', title: 'Messaging', desc: 'Slack,\nTelegram & more.' }, ]; +function showRestartPrompt(): void { + Alert.alert( + 'Pro activated', + 'Force-close and reopen the app to unlock Pro features.', + [{ text: 'OK' }], + ); +} export const ProDetailScreen: React.FC = () => { - const { colors, isDark } = useTheme(); + const { isDark } = useTheme(); + const { colors } = useTheme(); const styles = useThemedStyles(createStyles); - const setHasRegisteredPro = useAppStore((s) => s.setHasRegisteredPro); + const hasRegisteredPro = useAppStore((s) => s.hasRegisteredPro); + const [loading, setLoading] = useState(false); + + const handleGetPro = async () => { + setLoading(true); + try { + const purchased = await presentProPaywall(); + if (purchased) { + showRestartPrompt(); + } + } catch { + Alert.alert('Purchase failed', 'Something went wrong. Please try again.'); + } finally { + setLoading(false); + } + }; - const handleCTA = () => { - setHasRegisteredPro(true); - Linking.openURL(PRO_URL); + const handleRestore = async () => { + setLoading(true); + try { + const restored = await restorePro(); + if (restored) { + showRestartPrompt(); + } else { + Alert.alert('No purchases found', 'No active Pro subscription was found for this account.'); + } + } catch { + Alert.alert('Restore failed', 'Something went wrong. Please try again.'); + } finally { + setLoading(false); + } }; return ( @@ -49,9 +83,20 @@ export const ProDetailScreen: React.FC = () => { Off Grid Pro - - Get Pro - + {hasRegisteredPro ? ( + + + Pro Active + + ) : ( + + {loading ? 'Loading...' : 'Get Pro'} + + )} {/* Hero */} @@ -134,10 +179,44 @@ export const ProDetailScreen: React.FC = () => { - {/* CTA */} - - I am in 🔥 - + {/* CTA / Pro active */} + {hasRegisteredPro ? ( + <> + + + Pro is active on this account. + + {__DEV__ && ( + { + await resetProIdentityForTesting(); + Alert.alert('Dev reset', 'RC identity reset. Restart the app to test purchase from scratch.'); + }} + > + [DEV] Reset Pro identity + + )} + + ) : ( + <> + + {loading ? 'Loading...' : 'Get Pro'} + + + + Restore purchases + + + )} @@ -183,6 +262,16 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ borderRadius: 20, }, getProButtonText: { ...TYPOGRAPHY.bodySmall, color: '#FFFFFF' }, + proActiveBadge: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: SPACING.xs, + backgroundColor: colors.primary, + paddingHorizontal: SPACING.md, + paddingVertical: SPACING.sm, + borderRadius: 20, + }, + proActiveBadgeText: { ...TYPOGRAPHY.bodySmall, color: '#FFFFFF' }, // Hero hero: { @@ -348,19 +437,48 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ // CTA ctaButton: { marginHorizontal: SPACING.xl, - marginBottom: SPACING.xl, + marginBottom: SPACING.md, backgroundColor: colors.primary, borderRadius: 12, paddingVertical: SPACING.lg, flexDirection: 'row' as const, alignItems: 'center' as const, justifyContent: 'center' as const, - gap: SPACING.sm, }, ctaText: { ...TYPOGRAPHY.body, color: '#FFFFFF', letterSpacing: 0.5, }, + restoreButton: { + marginHorizontal: SPACING.xl, + marginBottom: SPACING.xl, + paddingVertical: SPACING.md, + alignItems: 'center' as const, + }, + restoreText: { + ...TYPOGRAPHY.bodySmall, + color: colors.textSecondary, + }, + buttonDisabled: { + opacity: 0.5, + }, + // Pro active state + proActiveCard: { + marginHorizontal: SPACING.xl, + marginBottom: SPACING.xl, + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + gap: SPACING.sm, + paddingVertical: SPACING.lg, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.primary, + }, + proActiveText: { + ...TYPOGRAPHY.body, + color: colors.primary, + }, }); diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts index d8bd1c26..29f6a7f5 100644 --- a/src/services/proLicenseService.ts +++ b/src/services/proLicenseService.ts @@ -1,14 +1,16 @@ import { Platform } from 'react-native'; -// @ts-ignore — remove after: npm install react-native-purchases react-native-purchases-ui import Purchases, { LOG_LEVEL } from 'react-native-purchases'; -// @ts-ignore -import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui'; import * as Keychain from 'react-native-keychain'; +import logger from '../utils/logger'; +import { + RC_API_KEY_IOS, + RC_API_KEY_ANDROID, + RC_API_KEY_TEST_STORE, + USE_RC_TEST_STORE, +} from '../config/revenueCatKeys'; const KEYCHAIN_SERVICE = 'off-grid-pro-license'; -const ENTITLEMENT_ID = 'offgrid Pro'; -const RC_API_KEY_IOS = 'test_UDUmOVwoEWFUtYONRUfQOOjVisB'; -const RC_API_KEY_ANDROID = 'test_UDUmOVwoEWFUtYONRUfQOOjVisB'; +const ENTITLEMENT_ID = 'pro'; type ProLicense = { isPro: boolean; verifiedAt: number }; @@ -18,14 +20,24 @@ function setProInStore(isPro: boolean): void { } export function configureRevenueCat(): void { - Purchases.setLogLevel(__DEV__ ? LOG_LEVEL.DEBUG : LOG_LEVEL.ERROR); - Purchases.configure({ - apiKey: Platform.OS === 'ios' ? RC_API_KEY_IOS : RC_API_KEY_ANDROID, - }); + try { + Purchases.setLogLevel(__DEV__ ? LOG_LEVEL.DEBUG : LOG_LEVEL.ERROR); + const useTestStore = __DEV__ && USE_RC_TEST_STORE; + const apiKey = useTestStore + ? RC_API_KEY_TEST_STORE + : Platform.OS === 'ios' ? RC_API_KEY_IOS : RC_API_KEY_ANDROID; + logger.log(`[RC] configure platform=${Platform.OS} store=${useTestStore ? 'TEST' : Platform.OS} key=${apiKey.slice(0, 12)}...`); + Purchases.configure({ apiKey }); + logger.log('[RC] configure: SDK configured OK'); + } catch (e: any) { + logger.error(`[RC] configure FAILED: ${e?.message ?? e}`); + throw e; + } } async function writeLicense(isPro: boolean): Promise { const license: ProLicense = { isPro, verifiedAt: Date.now() }; + logger.log(`[RC] writeLicense isPro=${isPro}`); await Keychain.setGenericPassword('license', JSON.stringify(license), { service: KEYCHAIN_SERVICE, accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK, @@ -35,48 +47,108 @@ async function writeLicense(isPro: boolean): Promise { export async function readProFromKeychain(): Promise { try { const result = await Keychain.getGenericPassword({ service: KEYCHAIN_SERVICE }); - if (!result) return false; + if (!result) { + logger.log('[RC] readProFromKeychain: no entry found → false'); + return false; + } const license: ProLicense = JSON.parse(result.password); + const age = Math.round((Date.now() - license.verifiedAt) / 1000); + logger.log(`[RC] readProFromKeychain: isPro=${license.isPro} verifiedAt=${license.verifiedAt} age=${age}s`); return license.isPro ?? false; - } catch { + } catch (e: any) { + logger.error(`[RC] readProFromKeychain error: ${e?.message ?? e}`); return false; } } export async function checkProStatus(): Promise { + logger.log('[RC] checkProStatus: reading keychain...'); const cached = await readProFromKeychain(); + logger.log(`[RC] checkProStatus: cached=${cached}, firing background sync`); syncWithRevenueCat().catch(() => {}); return cached; } async function syncWithRevenueCat(): Promise { try { + logger.log('[RC] syncWithRevenueCat: invalidating cache + fetching...'); + await Purchases.invalidateCustomerInfoCache(); const info = await Purchases.getCustomerInfo(); + const activeKeys = Object.keys(info.entitlements.active); + logger.log(`[RC] syncWithRevenueCat: customerID=${info.originalAppUserId}`); + logger.log(`[RC] syncWithRevenueCat: activeEntitlements=[${activeKeys.join(', ') || 'none'}]`); + logger.log(`[RC] syncWithRevenueCat: allPurchaseDates=${JSON.stringify(info.allPurchaseDates)}`); const isPro = info.entitlements.active[ENTITLEMENT_ID] !== undefined; + if (isPro) { + const ent = info.entitlements.active[ENTITLEMENT_ID]; + logger.log(`[RC] syncWithRevenueCat: entitlement productID=${ent?.productIdentifier} isSandbox=${ent?.isSandbox} unsubscribeDetected=${ent?.unsubscribeDetectedAt ?? 'null'}`); + } + logger.log(`[RC] syncWithRevenueCat: isPro=${isPro} → writing to keychain`); await writeLicense(isPro); setProInStore(isPro); - } catch { - // No network — cached value stands + logger.log('[RC] syncWithRevenueCat: done'); + } catch (e: any) { + logger.error(`[RC] syncWithRevenueCat error: ${e?.message ?? e}`); } } export async function presentProPaywall(): Promise { - const result: PAYWALL_RESULT = await RevenueCatUI.presentPaywall(); - switch (result) { - case PAYWALL_RESULT.PURCHASED: - case PAYWALL_RESULT.RESTORED: { + try { + logger.log('[RC] presentProPaywall: fetching offerings...'); + const offerings = await Purchases.getOfferings(); + logger.log(`[RC] presentProPaywall: availableOfferings=[${Object.keys(offerings.all).join(', ')}] current=${offerings.current?.identifier ?? 'none'}`); + const offering = offerings.current; + if (!offering) { + logger.error('[RC] presentProPaywall ABORT: no current offering (set one as current in RC)'); + throw new Error('No offering available'); + } + logger.log(`[RC] presentProPaywall: using offering=${offering.identifier} packages=${offering.availablePackages.length}`); + offering.availablePackages.forEach(p => + logger.log(`[RC] package=${p.identifier} product=${p.product?.identifier ?? 'NONE'} price=${p.product?.priceString ?? 'NONE'}`), + ); + const pkg = offering.availablePackages[0]; + if (!pkg) { + logger.error('[RC] presentProPaywall ABORT: no package in offering (no store product for this platform — check the package has an Android/iOS product)'); + throw new Error('No package available'); + } + logger.log(`[RC] presentProPaywall: purchasing package=${pkg.identifier} product=${pkg.product.identifier} price=${pkg.product.priceString}`); + + const { customerInfo } = await Purchases.purchasePackage(pkg); + const activeKeys = Object.keys(customerInfo.entitlements.active); + logger.log(`[RC] post-purchase activeEntitlements=[${activeKeys.join(', ') || 'none'}]`); + const isPro = customerInfo.entitlements.active[ENTITLEMENT_ID] !== undefined; + logger.log(`[RC] post-purchase isPro=${isPro} customerID=${customerInfo.originalAppUserId}`); + if (isPro) { + const ent = customerInfo.entitlements.active[ENTITLEMENT_ID]; + logger.log(`[RC] post-purchase entitlement isSandbox=${ent?.isSandbox} productID=${ent?.productIdentifier}`); + } + + if (isPro) { await writeLicense(true); setProInStore(true); return true; } - default: + return false; + } catch (e: any) { + if (e?.userCancelled) { + logger.log('[RC] presentProPaywall: user cancelled'); return false; + } + logger.error( + `[RC] presentProPaywall FAILED code=${e?.code} readable=${e?.readableErrorCode ?? 'n/a'} ` + + `msg=${e?.message ?? e} underlying=${e?.underlyingErrorMessage ?? 'n/a'}`, + ); + throw e; } } export async function restorePro(): Promise { + logger.log('[RC] restorePro: start'); const info = await Purchases.restorePurchases(); + const activeKeys = Object.keys(info.entitlements.active); + logger.log(`[RC] restorePro: activeEntitlements=[${activeKeys.join(', ') || 'none'}]`); const isPro = info.entitlements.active[ENTITLEMENT_ID] !== undefined; + logger.log(`[RC] restorePro: isPro=${isPro}`); await writeLicense(isPro); setProInStore(isPro); return isPro; @@ -86,3 +158,32 @@ export async function clearProForTesting(): Promise { await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE }); setProInStore(false); } + +export async function resetProIdentityForTesting(): Promise { + logger.log('[RC] resetProIdentityForTesting: start'); + logger.log('[RC] resetProIdentityForTesting: invalidating RC cache...'); + await Purchases.invalidateCustomerInfoCache(); + try { + const before = await Purchases.getCustomerInfo(); + const isAnonymous = before.originalAppUserId.startsWith('$RCAnonymousID:'); + logger.log(`[RC] resetProIdentityForTesting: customerID before=${before.originalAppUserId} anonymous=${isAnonymous}`); + logger.log(`[RC] resetProIdentityForTesting: entitlements before=[${Object.keys(before.entitlements.active).join(', ') || 'none'}]`); + logger.log(`[RC] resetProIdentityForTesting: allPurchases before=${JSON.stringify(before.allPurchaseDates)}`); + // logOut only works for identified users. Anonymous users can only be reset + // by deleting the app (which clears the anonymous ID from UserDefaults). + if (!isAnonymous) { + await Purchases.logOut(); + await Purchases.invalidateCustomerInfoCache(); + const after = await Purchases.getCustomerInfo(); + logger.log(`[RC] resetProIdentityForTesting: logOut done, customerID after=${after.originalAppUserId}`); + } else { + logger.log('[RC] resetProIdentityForTesting: anonymous user — skipping logOut (delete the app to get a fresh ID)'); + } + } catch (e: any) { + logger.error(`[RC] resetProIdentityForTesting: ${e?.message ?? e} — continuing`); + } + logger.log('[RC] resetProIdentityForTesting: clearing keychain...'); + await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE }); + setProInStore(false); + logger.log('[RC] resetProIdentityForTesting: done'); +} From 92d0312d2392a1499aa85d9568b06a8a4f583f45 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 18 Jun 2026 13:55:30 +0530 Subject: [PATCH 25/28] fix: harden Pro boot/purchase flow per PR review Address Gemini review on PR #404: - App.tsx: isolate RevenueCat + Pro init in its own try/catch so an RC failure (missing native module, locked keychain, bad config) can never abort app init or hang the splash screen. - proLicenseService: keychain writes no longer throw out of writeLicense. A persist failure after a successful charge would otherwise surface as a bogus 'Purchase failed'/'Restore failed'; the entitlement is still live on RC and the next sync re-writes the cache, so we log and continue. - proLicenseService: select the $rc_lifetime package explicitly instead of availablePackages[0], falling back to the first package, so RC package reordering or future monthly/yearly packages don't break purchase. - ProDetailScreen: collapse the two useTheme() calls into one. Tests: add ProDetailScreen RNTL coverage (purchase/restore/dev-reset states) and unit coverage for the keychain-failure path, package fallback, configure failure, and resetProIdentityForTesting branches. Co-Authored-By: Dishit Karia --- App.tsx | 18 ++- .../rntl/screens/ProDetailScreen.test.tsx | 118 ++++++++++++++++++ .../unit/services/proLicenseService.test.ts | 83 ++++++++++++ src/screens/ProDetailScreen/index.tsx | 3 +- src/services/proLicenseService.ts | 23 +++- 5 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 __tests__/rntl/screens/ProDetailScreen.test.tsx diff --git a/App.tsx b/App.tsx index 3c544dec..646d204a 100644 --- a/App.tsx +++ b/App.tsx @@ -175,11 +175,19 @@ function App() { // Configure RevenueCat and read the cached entitlement before Pro features load. // configureRevenueCat is sync; checkProStatus reads the keychain cache immediately // and fires a background RC network sync so the next launch stays fresh. - configureRevenueCat(); - await checkProStatus(); - - // Load pro features — only activates if the keychain entitlement is set. - await loadProFeatures(); + // + // Pro is optional: a failure here (missing native module, keychain locked, + // bad RC config) must never abort app init or hang the splash screen, so the + // whole block is isolated and only logs on error. + try { + configureRevenueCat(); + await checkProStatus(); + + // Load pro features — only activates if the keychain entitlement is set. + await loadProFeatures(); + } catch (proError) { + logger.error('[App] Pro initialization failed, continuing without Pro:', proError); + } // Show the UI immediately setIsInitializing(false); diff --git a/__tests__/rntl/screens/ProDetailScreen.test.tsx b/__tests__/rntl/screens/ProDetailScreen.test.tsx new file mode 100644 index 00000000..39a6bc93 --- /dev/null +++ b/__tests__/rntl/screens/ProDetailScreen.test.tsx @@ -0,0 +1,118 @@ +/** + * ProDetailScreen Tests + * + * Renders the real Pro screen and exercises the purchase / restore handlers + * and the entitlement-driven UI states (Get Pro vs Pro Active). + */ + +import React from 'react'; +import { Alert } from 'react-native'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { useAppStore } from '../../../src/stores/appStore'; + +const mockPresentProPaywall = jest.fn(); +const mockRestorePro = jest.fn(); +const mockResetProIdentityForTesting = jest.fn(); +jest.mock('../../../src/services/proLicenseService', () => ({ + presentProPaywall: (...args: unknown[]) => mockPresentProPaywall(...args), + restorePro: (...args: unknown[]) => mockRestorePro(...args), + resetProIdentityForTesting: (...args: unknown[]) => mockResetProIdentityForTesting(...args), +})); + +import { ProDetailScreen } from '../../../src/screens/ProDetailScreen'; + +describe('ProDetailScreen', () => { + let alertSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + useAppStore.setState({ hasRegisteredPro: false }); + alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + }); + + afterEach(() => { + alertSpy.mockRestore(); + }); + + it('renders the Get Pro call-to-action when the user is not Pro', () => { + const { queryAllByText } = render(); + expect(queryAllByText('Get Pro').length).toBeGreaterThan(0); + }); + + it('shows the restart prompt after a successful purchase', async () => { + mockPresentProPaywall.mockResolvedValueOnce(true); + const { getAllByText } = render(); + + fireEvent.press(getAllByText('Get Pro')[0]); + + await waitFor(() => expect(mockPresentProPaywall).toHaveBeenCalledTimes(1)); + expect(alertSpy).toHaveBeenCalledWith('Pro activated', expect.any(String), expect.anything()); + }); + + it('does not show the restart prompt when the purchase is not completed', async () => { + mockPresentProPaywall.mockResolvedValueOnce(false); + const { getAllByText } = render(); + + fireEvent.press(getAllByText('Get Pro')[0]); + + await waitFor(() => expect(mockPresentProPaywall).toHaveBeenCalledTimes(1)); + expect(alertSpy).not.toHaveBeenCalled(); + }); + + it('shows a failure alert when the purchase throws', async () => { + mockPresentProPaywall.mockRejectedValueOnce(new Error('boom')); + const { getAllByText } = render(); + + fireEvent.press(getAllByText('Get Pro')[0]); + + await waitFor(() => expect(alertSpy).toHaveBeenCalledWith('Purchase failed', expect.any(String))); + }); + + it('shows the restart prompt when a restore finds an active subscription', async () => { + mockRestorePro.mockResolvedValueOnce(true); + const { getByText } = render(); + + fireEvent.press(getByText('Restore purchases')); + + await waitFor(() => expect(mockRestorePro).toHaveBeenCalledTimes(1)); + expect(alertSpy).toHaveBeenCalledWith('Pro activated', expect.any(String), expect.anything()); + }); + + it('tells the user when a restore finds no purchases', async () => { + mockRestorePro.mockResolvedValueOnce(false); + const { getByText } = render(); + + fireEvent.press(getByText('Restore purchases')); + + await waitFor(() => expect(alertSpy).toHaveBeenCalledWith('No purchases found', expect.any(String))); + }); + + it('shows a failure alert when the restore throws', async () => { + mockRestorePro.mockRejectedValueOnce(new Error('boom')); + const { getByText } = render(); + + fireEvent.press(getByText('Restore purchases')); + + await waitFor(() => expect(alertSpy).toHaveBeenCalledWith('Restore failed', expect.any(String))); + }); + + it('renders the Pro Active state when the user already owns Pro', () => { + useAppStore.setState({ hasRegisteredPro: true }); + const { getByText, queryByText } = render(); + + expect(getByText('Pro Active')).toBeTruthy(); + expect(getByText('Pro is active on this account.')).toBeTruthy(); + expect(queryByText('Restore purchases')).toBeNull(); + }); + + it('runs the dev reset and confirms when the Pro user taps [DEV] reset', async () => { + useAppStore.setState({ hasRegisteredPro: true }); + mockResetProIdentityForTesting.mockResolvedValueOnce(undefined); + const { getByText } = render(); + + fireEvent.press(getByText('[DEV] Reset Pro identity')); + + await waitFor(() => expect(mockResetProIdentityForTesting).toHaveBeenCalledTimes(1)); + expect(alertSpy).toHaveBeenCalledWith('Dev reset', expect.any(String)); + }); +}); diff --git a/__tests__/unit/services/proLicenseService.test.ts b/__tests__/unit/services/proLicenseService.test.ts index 676e49c9..6ceb17d7 100644 --- a/__tests__/unit/services/proLicenseService.test.ts +++ b/__tests__/unit/services/proLicenseService.test.ts @@ -5,6 +5,7 @@ import { restorePro, clearProForTesting, configureRevenueCat, + resetProIdentityForTesting, } from '../../../src/services/proLicenseService'; jest.mock('react-native-purchases', () => ({ @@ -118,6 +119,31 @@ describe('proLicenseService', () => { expect(await presentProPaywall()).toBe(false); expect(mockSetHasRegisteredPro).not.toHaveBeenCalled(); }); + + it('still reports success when the keychain write fails after a granted purchase', async () => { + Purchases.getOfferings.mockResolvedValueOnce(makeOffering()); + Purchases.purchasePackage.mockResolvedValueOnce({ + customerInfo: { entitlements: { active: ENTITLEMENT_ACTIVE }, originalAppUserId: 'anon' }, + }); + // A keychain failure must not turn a charged purchase into a "Purchase failed". + mockSetGenericPassword.mockRejectedValueOnce(new Error('keychain locked')); + expect(await presentProPaywall()).toBe(true); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); + }); + + it('falls back to the first package when no $rc_lifetime package exists', async () => { + const offering = makeOffering(); + offering.current.availablePackages = [ + { identifier: '$rc_monthly', product: { identifier: 'off_grid_pro_monthly', priceString: '$1.99' } }, + ]; + Purchases.getOfferings.mockResolvedValueOnce(offering); + Purchases.purchasePackage.mockResolvedValueOnce({ + customerInfo: { entitlements: { active: ENTITLEMENT_ACTIVE }, originalAppUserId: 'anon' }, + }); + mockSetGenericPassword.mockResolvedValueOnce(true); + expect(await presentProPaywall()).toBe(true); + expect(Purchases.purchasePackage).toHaveBeenCalledWith(offering.current.availablePackages[0]); + }); }); describe('restorePro()', () => { @@ -145,6 +171,21 @@ describe('proLicenseService', () => { }); }); + describe('presentProPaywall() error paths', () => { + it('throws when there is no current offering', async () => { + Purchases.getOfferings.mockResolvedValueOnce({ all: {}, current: null }); + await expect(presentProPaywall()).rejects.toThrow('No offering available'); + }); + + it('throws when the current offering has no packages', async () => { + Purchases.getOfferings.mockResolvedValueOnce({ + all: {}, + current: { identifier: 'default', availablePackages: [] }, + }); + await expect(presentProPaywall()).rejects.toThrow('No package available'); + }); + }); + describe('configureRevenueCat()', () => { it('configures RC SDK on iOS', () => { const Platform = require('react-native').Platform; @@ -159,5 +200,47 @@ describe('proLicenseService', () => { configureRevenueCat(); expect(Purchases.configure).toHaveBeenCalledTimes(1); }); + + it('rethrows when the RC SDK fails to configure', () => { + Purchases.configure.mockImplementationOnce(() => { + throw new Error('native module missing'); + }); + expect(() => configureRevenueCat()).toThrow('native module missing'); + }); + }); + + describe('resetProIdentityForTesting()', () => { + it('skips logOut for an anonymous user and clears the keychain', async () => { + Purchases.getCustomerInfo.mockResolvedValueOnce({ + originalAppUserId: '$RCAnonymousID:abc', + entitlements: { active: {} }, + allPurchaseDates: {}, + }); + await resetProIdentityForTesting(); + expect(Purchases.logOut).not.toHaveBeenCalled(); + expect(mockResetGenericPassword).toHaveBeenCalledTimes(1); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(false); + }); + + it('logs out an identified user before clearing the keychain', async () => { + Purchases.getCustomerInfo + .mockResolvedValueOnce({ + originalAppUserId: 'real-user-123', + entitlements: { active: {} }, + allPurchaseDates: {}, + }) + .mockResolvedValueOnce({ originalAppUserId: '$RCAnonymousID:new', entitlements: { active: {} } }); + Purchases.logOut.mockResolvedValueOnce(undefined); + await resetProIdentityForTesting(); + expect(Purchases.logOut).toHaveBeenCalledTimes(1); + expect(mockResetGenericPassword).toHaveBeenCalledTimes(1); + }); + + it('continues clearing the keychain when the RC lookup throws', async () => { + Purchases.getCustomerInfo.mockRejectedValueOnce(new Error('network down')); + await resetProIdentityForTesting(); + expect(mockResetGenericPassword).toHaveBeenCalledTimes(1); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(false); + }); }); }); diff --git a/src/screens/ProDetailScreen/index.tsx b/src/screens/ProDetailScreen/index.tsx index 1600f169..49404d61 100644 --- a/src/screens/ProDetailScreen/index.tsx +++ b/src/screens/ProDetailScreen/index.tsx @@ -25,8 +25,7 @@ function showRestartPrompt(): void { } export const ProDetailScreen: React.FC = () => { - const { isDark } = useTheme(); - const { colors } = useTheme(); + const { colors, isDark } = useTheme(); const styles = useThemedStyles(createStyles); const hasRegisteredPro = useAppStore((s) => s.hasRegisteredPro); const [loading, setLoading] = useState(false); diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts index 29f6a7f5..82ebc7ee 100644 --- a/src/services/proLicenseService.ts +++ b/src/services/proLicenseService.ts @@ -38,10 +38,19 @@ export function configureRevenueCat(): void { async function writeLicense(isPro: boolean): Promise { const license: ProLicense = { isPro, verifiedAt: Date.now() }; logger.log(`[RC] writeLicense isPro=${isPro}`); - await Keychain.setGenericPassword('license', JSON.stringify(license), { - service: KEYCHAIN_SERVICE, - accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK, - }); + try { + await Keychain.setGenericPassword('license', JSON.stringify(license), { + service: KEYCHAIN_SERVICE, + accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK, + }); + } catch (e) { + // A keychain write failure (locked keychain, unsupported platform) must not + // surface as a "Purchase failed"/"Restore failed" after the user was charged. + // The entitlement is still live on RevenueCat and the next background sync + // re-writes the cache, so log and continue rather than throwing. + const message = e instanceof Error ? e.message : String(e); + logger.error(`[RC] writeLicense failed to persist to keychain: ${message}`); + } } export async function readProFromKeychain(): Promise { @@ -106,7 +115,11 @@ export async function presentProPaywall(): Promise { offering.availablePackages.forEach(p => logger.log(`[RC] package=${p.identifier} product=${p.product?.identifier ?? 'NONE'} price=${p.product?.priceString ?? 'NONE'}`), ); - const pkg = offering.availablePackages[0]; + // Prefer the lifetime package explicitly. Relying on availablePackages[0] is + // fragile: RC can reorder packages or add new ones (monthly/yearly) later. + const pkg = + offering.availablePackages.find(p => p.identifier === '$rc_lifetime') ?? + offering.availablePackages[0]; if (!pkg) { logger.error('[RC] presentProPaywall ABORT: no package in offering (no store product for this platform — check the package has an Android/iOS product)'); throw new Error('No package available'); From 0a167fcdd05457e00a0b0094073678249dcdfacd Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 18 Jun 2026 14:41:09 +0530 Subject: [PATCH 26/28] fix: guard RC entry points and reuse boot entitlement read Second round of Gemini review on PR #404: - proLicenseService: track an isConfigured flag set by configureRevenueCat, and skip configuration on platforms react-native-purchases does not support (anything but iOS/Android, e.g. web). presentProPaywall and restorePro now fail loudly, and syncWithRevenueCat / resetProIdentityFor- Testing no-op, when the SDK was never configured, instead of hitting an absent native module. - loadProFeatures: accept an optional pre-read isPro so the boot path can reuse checkProStatus()'s keychain read instead of a second round-trip; standalone callers still fall back to reading the keychain. - App.tsx: pass the checkProStatus() result into loadProFeatures(). Tests: cover the unconfigured guard branches (isolated module), the web platform skip, and the loadProFeatures isPro fast-path. Co-Authored-By: Dishit Karia --- App.tsx | 5 +- .../unit/services/loadProFeatures.test.ts | 16 ++++++ .../unit/services/proLicenseService.test.ts | 55 +++++++++++++++++++ src/bootstrap/loadProFeatures.ts | 8 ++- src/services/proLicenseService.ts | 27 +++++++++ 5 files changed, 106 insertions(+), 5 deletions(-) diff --git a/App.tsx b/App.tsx index 646d204a..4064597f 100644 --- a/App.tsx +++ b/App.tsx @@ -181,10 +181,11 @@ function App() { // whole block is isolated and only logs on error. try { configureRevenueCat(); - await checkProStatus(); + const isPro = await checkProStatus(); // Load pro features — only activates if the keychain entitlement is set. - await loadProFeatures(); + // Reuse the entitlement read above to avoid a second keychain round-trip. + await loadProFeatures(isPro); } catch (proError) { logger.error('[App] Pro initialization failed, continuing without Pro:', proError); } diff --git a/__tests__/unit/services/loadProFeatures.test.ts b/__tests__/unit/services/loadProFeatures.test.ts index 56e0b4f3..03f5e9ab 100644 --- a/__tests__/unit/services/loadProFeatures.test.ts +++ b/__tests__/unit/services/loadProFeatures.test.ts @@ -52,4 +52,20 @@ describe('loadProFeatures()', () => { }), ); }); + + it('reuses a passed isPro=true without re-reading the keychain', async () => { + const mockActivate = jest.fn(); + jest.mock('@offgrid/pro', () => ({ activate: mockActivate }), { virtual: true }); + await loadProFeatures(true); + expect(mockActivate).toHaveBeenCalledTimes(1); + expect(mockReadProFromKeychain).not.toHaveBeenCalled(); + }); + + it('reuses a passed isPro=false without re-reading the keychain', async () => { + const mockActivate = jest.fn(); + jest.mock('@offgrid/pro', () => ({ activate: mockActivate }), { virtual: true }); + await loadProFeatures(false); + expect(mockActivate).not.toHaveBeenCalled(); + expect(mockReadProFromKeychain).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/unit/services/proLicenseService.test.ts b/__tests__/unit/services/proLicenseService.test.ts index 6ceb17d7..6fcf1e15 100644 --- a/__tests__/unit/services/proLicenseService.test.ts +++ b/__tests__/unit/services/proLicenseService.test.ts @@ -53,6 +53,15 @@ const ENTITLEMENT_ACTIVE = { pro: { productIdentifier: 'pro_monthly' } }; const ENTITLEMENT_EMPTY = {}; describe('proLicenseService', () => { + beforeAll(() => { + // configureRevenueCat sets the module-level isConfigured flag that the + // purchase/restore/reset entry points require. Pin Platform.OS first since + // its default varies in the RN test environment. + const Platform = require('react-native').Platform; + Platform.OS = 'ios'; + configureRevenueCat(); + }); + beforeEach(() => { jest.clearAllMocks(); }); @@ -202,11 +211,57 @@ describe('proLicenseService', () => { }); it('rethrows when the RC SDK fails to configure', () => { + const Platform = require('react-native').Platform; + Platform.OS = 'ios'; Purchases.configure.mockImplementationOnce(() => { throw new Error('native module missing'); }); expect(() => configureRevenueCat()).toThrow('native module missing'); }); + + it('skips configuration on unsupported platforms (e.g. web)', () => { + const Platform = require('react-native').Platform; + Platform.OS = 'web'; + configureRevenueCat(); + expect(Purchases.configure).not.toHaveBeenCalled(); + Platform.OS = 'ios'; + }); + }); + + describe('guards when the SDK is not configured', () => { + // A fresh module instance where configureRevenueCat() never ran, so the + // module-level isConfigured flag is false. + let svc: typeof import('../../../src/services/proLicenseService'); + let isolatedKeychain: { getGenericPassword: jest.Mock; resetGenericPassword: jest.Mock }; + let isolatedPurchases: { getCustomerInfo: jest.Mock }; + + beforeEach(() => { + jest.isolateModules(() => { + svc = require('../../../src/services/proLicenseService'); + isolatedKeychain = require('react-native-keychain'); + isolatedPurchases = require('react-native-purchases').default; + }); + }); + + it('presentProPaywall throws', async () => { + await expect(svc.presentProPaywall()).rejects.toThrow('RevenueCat is not configured'); + }); + + it('restorePro throws', async () => { + await expect(svc.restorePro()).rejects.toThrow('RevenueCat is not configured'); + }); + + it('resetProIdentityForTesting no-ops without touching the keychain', async () => { + await svc.resetProIdentityForTesting(); + expect(isolatedKeychain.resetGenericPassword).not.toHaveBeenCalled(); + }); + + it('checkProStatus does not fire a background sync', async () => { + isolatedKeychain.getGenericPassword.mockResolvedValue(false); + expect(await svc.checkProStatus()).toBe(false); + await new Promise(resolve => setImmediate(resolve)); + expect(isolatedPurchases.getCustomerInfo).not.toHaveBeenCalled(); + }); }); describe('resetProIdentityForTesting()', () => { diff --git a/src/bootstrap/loadProFeatures.ts b/src/bootstrap/loadProFeatures.ts index 016c8b9b..4ff06b14 100644 --- a/src/bootstrap/loadProFeatures.ts +++ b/src/bootstrap/loadProFeatures.ts @@ -3,7 +3,7 @@ import { registerScreen } from '../navigation/screenRegistry'; import { registerSettingsSection } from '../components/settings/sectionRegistry'; import { readProFromKeychain } from '../services/proLicenseService'; -export async function loadProFeatures(): Promise { +export async function loadProFeatures(isPro?: boolean): Promise { let pro: any; try { pro = require('@offgrid/pro'); @@ -14,8 +14,10 @@ export async function loadProFeatures(): Promise { return; // proStub.js returns null — free build via metro extraNodeModules } - const isPro = await readProFromKeychain(); - if (!isPro) { + // The boot path already read the entitlement in checkProStatus(); reuse it to + // avoid a second keychain round-trip. Fall back to a read for standalone callers. + const active = isPro ?? (await readProFromKeychain()); + if (!active) { return; // paid features stay dormant until the user purchases } diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts index 82ebc7ee..fdbf0bae 100644 --- a/src/services/proLicenseService.ts +++ b/src/services/proLicenseService.ts @@ -12,6 +12,12 @@ import { const KEYCHAIN_SERVICE = 'off-grid-pro-license'; const ENTITLEMENT_ID = 'pro'; +// react-native-purchases only ships native modules for iOS and Android. On any +// other platform (e.g. React Native Web) configure is skipped and this stays +// false, so the RC-backed entry points below no-op or fail loudly instead of +// throwing native "module not found" errors. +let isConfigured = false; + type ProLicense = { isPro: boolean; verifiedAt: number }; function setProInStore(isPro: boolean): void { @@ -20,6 +26,10 @@ function setProInStore(isPro: boolean): void { } export function configureRevenueCat(): void { + if (Platform.OS !== 'ios' && Platform.OS !== 'android') { + logger.log(`[RC] configure skipped: unsupported platform ${Platform.OS}`); + return; + } try { Purchases.setLogLevel(__DEV__ ? LOG_LEVEL.DEBUG : LOG_LEVEL.ERROR); const useTestStore = __DEV__ && USE_RC_TEST_STORE; @@ -28,6 +38,7 @@ export function configureRevenueCat(): void { : Platform.OS === 'ios' ? RC_API_KEY_IOS : RC_API_KEY_ANDROID; logger.log(`[RC] configure platform=${Platform.OS} store=${useTestStore ? 'TEST' : Platform.OS} key=${apiKey.slice(0, 12)}...`); Purchases.configure({ apiKey }); + isConfigured = true; logger.log('[RC] configure: SDK configured OK'); } catch (e: any) { logger.error(`[RC] configure FAILED: ${e?.message ?? e}`); @@ -79,6 +90,10 @@ export async function checkProStatus(): Promise { } async function syncWithRevenueCat(): Promise { + if (!isConfigured) { + logger.log('[RC] syncWithRevenueCat skipped: SDK not configured'); + return; + } try { logger.log('[RC] syncWithRevenueCat: invalidating cache + fetching...'); await Purchases.invalidateCustomerInfoCache(); @@ -102,6 +117,10 @@ async function syncWithRevenueCat(): Promise { } export async function presentProPaywall(): Promise { + if (!isConfigured) { + logger.error('[RC] presentProPaywall ABORT: SDK not configured'); + throw new Error('RevenueCat is not configured'); + } try { logger.log('[RC] presentProPaywall: fetching offerings...'); const offerings = await Purchases.getOfferings(); @@ -156,6 +175,10 @@ export async function presentProPaywall(): Promise { } export async function restorePro(): Promise { + if (!isConfigured) { + logger.error('[RC] restorePro ABORT: SDK not configured'); + throw new Error('RevenueCat is not configured'); + } logger.log('[RC] restorePro: start'); const info = await Purchases.restorePurchases(); const activeKeys = Object.keys(info.entitlements.active); @@ -173,6 +196,10 @@ export async function clearProForTesting(): Promise { } export async function resetProIdentityForTesting(): Promise { + if (!isConfigured) { + logger.log('[RC] resetProIdentityForTesting skipped: SDK not configured'); + return; + } logger.log('[RC] resetProIdentityForTesting: start'); logger.log('[RC] resetProIdentityForTesting: invalidating RC cache...'); await Purchases.invalidateCustomerInfoCache(); From 98b32bc7c1a22fe444122c9b6ede7ceedbd977e7 Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 18 Jun 2026 16:53:20 +0530 Subject: [PATCH 27/28] fix: make calendar tool reliable with date context and iOS 17 permission Inject the current device-local date into the tool system prompt so on-device models can resolve relative dates without chaining a separate datetime tool. A precise minute/second timestamp is used only when a calendar tool is enabled (so "in half an hour" resolves), and a cache-friendly date-only line otherwise, preserving llama.rn prompt-prefix reuse for non-calendar sessions. Make create_calendar_event end_date optional, defaulting to one hour after the start, and tighten the tool/parameter descriptions. Add NSCalendarsFullAccessUsageDescription to the iOS Info.plist so calendar read/create works on iOS 17+ (the library uses the EventKit full-access flow, which requires this key). Co-Authored-By: Dishit Karia --- __tests__/unit/services/toolHandlers.test.ts | 2 +- .../unit/services/tools/handlers.test.ts | 23 +++++++++- ios/OffgridMobile/Info.plist | 2 + src/services/generationToolLoop.ts | 45 +++++++++++++++++-- src/services/tools/handlers.ts | 18 ++++---- src/services/tools/registry.ts | 7 ++- 6 files changed, 79 insertions(+), 18 deletions(-) diff --git a/__tests__/unit/services/toolHandlers.test.ts b/__tests__/unit/services/toolHandlers.test.ts index 596dbfbe..f7ba7df1 100644 --- a/__tests__/unit/services/toolHandlers.test.ts +++ b/__tests__/unit/services/toolHandlers.test.ts @@ -324,7 +324,7 @@ describe('calendar handlers', () => { name: 'create_calendar_event', arguments: { title: 'Meeting', start_date: 'not-a-date', end_date: '2025-01-01T11:00:00Z' }, }); - expect(result.error).toContain('Invalid date format'); + expect(result.error).toContain('Invalid start_date'); }); it('creates event and returns success message', async () => { diff --git a/__tests__/unit/services/tools/handlers.test.ts b/__tests__/unit/services/tools/handlers.test.ts index 0684dcf9..0385ea2b 100644 --- a/__tests__/unit/services/tools/handlers.test.ts +++ b/__tests__/unit/services/tools/handlers.test.ts @@ -624,7 +624,7 @@ describe('Tool Handlers', () => { expect(mockSaveEvent).not.toHaveBeenCalled(); }); - it('returns an error for invalid dates', async () => { + it('returns an error for an invalid start date', async () => { mockRequestPermissions.mockResolvedValue('authorized'); const result = await runTool('create_calendar_event', { @@ -633,10 +633,29 @@ describe('Tool Handlers', () => { end_date: 'also-bad', }); - expect(result.error).toContain('Invalid date format'); + expect(result.error).toContain('Invalid start_date'); expect(mockSaveEvent).not.toHaveBeenCalled(); }); + it('defaults end_date to one hour after start when omitted', async () => { + mockRequestPermissions.mockResolvedValue('authorized'); + mockSaveEvent.mockResolvedValue('event-id-3'); + + const result = await runTool('create_calendar_event', { + title: 'Lunch', + start_date: '2026-07-01T13:00:00.000Z', + }); + + expect(result.error).toBeUndefined(); + expect(mockSaveEvent).toHaveBeenCalledWith( + 'Lunch', + expect.objectContaining({ + startDate: '2026-07-01T13:00:00.000Z', + endDate: '2026-07-01T14:00:00.000Z', + }), + ); + }); + it('omits optional fields when not provided', async () => { mockRequestPermissions.mockResolvedValue('authorized'); mockSaveEvent.mockResolvedValue('event-id-2'); diff --git a/ios/OffgridMobile/Info.plist b/ios/OffgridMobile/Info.plist index 0a2816f2..d196b811 100644 --- a/ios/OffgridMobile/Info.plist +++ b/ios/OffgridMobile/Info.plist @@ -51,6 +51,8 @@ This app needs access to your photo library to attach images to conversations. NSCalendarsUsageDescription Used to read and create calendar events on your request. + NSCalendarsFullAccessUsageDescription + Used to read and create calendar events on your request. NSSpeechRecognitionUsageDescription This app uses on-device speech recognition to transcribe voice input. RCTNewArchEnabled diff --git a/src/services/generationToolLoop.ts b/src/services/generationToolLoop.ts index 1c6e5c3f..d1cf087d 100644 --- a/src/services/generationToolLoop.ts +++ b/src/services/generationToolLoop.ts @@ -385,13 +385,52 @@ async function callLiteRTForLoop( const TOOL_BEHAVIOR_GUIDANCE = '\n\nMake good use of the tools available to you. If you are uncertain or lack current information, use the appropriate tool rather than guessing. Never refuse or say you cannot help when a tool is available. For multiple distinct items, make a separate tool call for each. Call tools silently — do not announce them first.'; -function augmentSystemPromptForTools(messages: Message[]): Message[] { +/** Tools that need precise time-of-day to resolve relative phrases like "in half an hour". */ +const TIME_SENSITIVE_TOOL_IDS = ['create_calendar_event', 'read_calendar_events']; + +/** + * Build a current-date(/time) context line for the system prompt. On-device models + * have no built-in clock, so without this they cannot resolve relative dates + * ("tomorrow", "next Friday") into the ISO timestamps the calendar tools need. + * + * `precise` controls the prompt-cache tradeoff: + * - true -> full minute/second timestamp, so "in half an hour" resolves correctly. + * The timestamp changes every turn, which breaks llama.rn prefix-cache reuse from + * this point on. Only used when a time-sensitive tool (calendar) is enabled. + * - false -> date only. Stable for the whole day, so the prompt cache is preserved; + * day-relative phrasing still works, but sub-day phrasing does not. + * + * Computed at send-time (not module load) so it stays current across a session. + */ +function buildDateTimeContext(precise: boolean): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + let dayOfWeek = ''; + let tz = ''; + try { + dayOfWeek = now.toLocaleDateString(undefined, { weekday: 'long' }); + tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; + } catch { + // toLocaleDateString/Intl can be unavailable on some JS engines; date alone still helps. + } + const dayPart = dayOfWeek ? ` Today is ${dayOfWeek}.` : ''; + const tzPart = tz ? ` Timezone: ${tz}.` : ''; + if (precise) { + const local = `${dateStr}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; + return `\n\nThe current date and time is ${local} (device local time, format YYYY-MM-DDTHH:MM:SS).${dayPart}${tzPart} When the user refers to relative dates or times such as "today", "tomorrow", "next Friday", or "in half an hour", resolve them against this current date and time.`; + } + return `\n\nThe current date is ${dateStr} (device local date, format YYYY-MM-DD).${dayPart}${tzPart} When the user refers to relative dates such as "today", "tomorrow", or "next Friday", resolve them against this current date.`; +} + +function augmentSystemPromptForTools(messages: Message[], enabledToolIds: string[] = []): Message[] { const sysIdx = messages.findIndex(m => m.role === 'system'); if (sysIdx === -1) return messages; const sys = messages[sysIdx]; const existing = typeof sys.content === 'string' ? sys.content : ''; const extHints = getToolExtensions().map(e => e.getSystemPromptHint()).filter(Boolean).join(''); - const updated = { ...sys, content: existing + TOOL_BEHAVIOR_GUIDANCE + extHints }; + const precise = enabledToolIds.some(id => TIME_SENSITIVE_TOOL_IDS.includes(id)); + const updated = { ...sys, content: existing + TOOL_BEHAVIOR_GUIDANCE + buildDateTimeContext(precise) + extHints }; return [...messages.slice(0, sysIdx), updated, ...messages.slice(sysIdx + 1)]; } @@ -409,7 +448,7 @@ async function callLLMWithRetry( // We shallow-copy messages to avoid mutating the caller's array. const exts = getToolExtensions(); const extCount = exts.reduce((n, e) => n + e.enabledToolCount(), 0); - const augmentedMessages = (tools.length > 0 || extCount > 0) ? augmentSystemPromptForTools(messages) : messages; + const augmentedMessages = (tools.length > 0 || extCount > 0) ? augmentSystemPromptForTools(messages, ctx?.enabledToolIds) : messages; if (isLiteRTActive() && conversationId) { return callLiteRTForLoop(conversationId, augmentedMessages, { tools, onStream, ctx }); diff --git a/src/services/tools/handlers.ts b/src/services/tools/handlers.ts index 297bc0e5..a48243f6 100644 --- a/src/services/tools/handlers.ts +++ b/src/services/tools/handlers.ts @@ -54,14 +54,12 @@ async function dispatchTool(call: ToolCall): Promise { case 'create_calendar_event': { const title = requireString(call, 'title'); const startDate = requireString(call, 'start_date'); - const endDate = requireString(call, 'end_date'); if (!title) throw new Error('Missing required parameter: title'); if (!startDate) throw new Error('Missing required parameter: start_date'); - if (!endDate) throw new Error('Missing required parameter: end_date'); return handleCreateCalendarEvent({ title, startDate, - endDate, + endDate: call.arguments.end_date as string | undefined, location: call.arguments.location as string | undefined, notes: call.arguments.notes as string | undefined, }); @@ -439,13 +437,17 @@ async function ensureCalendarPermission(readonly: boolean): Promise { } async function handleCreateCalendarEvent( - event: { title: string; startDate: string; endDate: string; location?: string; notes?: string }, + event: { title: string; startDate: string; endDate?: string; location?: string; notes?: string }, ): Promise { const { title, startDate, endDate, location, notes } = event; const start = new Date(startDate); - const end = new Date(endDate); - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { - throw new TypeError('Invalid date format. Please provide valid ISO 8601 dates.'); + if (Number.isNaN(start.getTime())) { + throw new TypeError('Invalid start_date. Please provide a valid ISO 8601 date, e.g. 2025-06-01T10:00:00.'); + } + // end_date is optional; default to one hour after the start when omitted. + const end = endDate ? new Date(endDate) : new Date(start.getTime() + 60 * 60 * 1000); + if (Number.isNaN(end.getTime())) { + throw new TypeError('Invalid end_date. Please provide a valid ISO 8601 date, e.g. 2025-06-01T11:00:00.'); } // requestPermissions(false) asks for read/write on Android; iOS is always read/write. await ensureCalendarPermission(false); @@ -457,7 +459,7 @@ async function handleCreateCalendarEvent( ...(notes ? { notes, description: notes } : {}), }); const locationSuffix = location ? ` at ${location}` : ''; - return `Calendar event "${title}" saved from ${startDate} to ${endDate}${locationSuffix}.`; + return `Calendar event "${title}" saved from ${start.toLocaleString()} to ${end.toLocaleString()}${locationSuffix}.`; } async function handleReadCalendarEvents(startDateStr?: string, endDateStr?: string): Promise { diff --git a/src/services/tools/registry.ts b/src/services/tools/registry.ts index a2485741..26bea8f7 100644 --- a/src/services/tools/registry.ts +++ b/src/services/tools/registry.ts @@ -112,7 +112,7 @@ export const AVAILABLE_TOOLS: ToolDefinition[] = [ id: 'create_calendar_event', name: 'create_calendar_event', displayName: 'Create Calendar Event', - description: 'Create an event in the device calendar', + description: 'Add an event to the device calendar. Use whenever the user asks to schedule, add, book, or set a reminder for something at a date or time. Times are device local time.', icon: 'calendar', parameters: { title: { @@ -122,13 +122,12 @@ export const AVAILABLE_TOOLS: ToolDefinition[] = [ }, start_date: { type: 'string', - description: 'Start date/time in ISO 8601 format, e.g. 2025-06-01T10:00:00', + description: 'Start date/time in ISO 8601 local format, e.g. 2025-06-01T10:00:00. Resolve relative dates ("tomorrow", "next Friday at 3pm") against the current date and time given in the system prompt.', required: true, }, end_date: { type: 'string', - description: 'End date/time in ISO 8601 format', - required: true, + description: 'End date/time in ISO 8601 local format. Optional - if omitted, the event defaults to one hour after the start.', }, location: { type: 'string', From 0a06cd18ab325145ca2690d242ea439cf584c99b Mon Sep 17 00:00:00 2001 From: Dishit Date: Thu, 18 Jun 2026 17:57:00 +0530 Subject: [PATCH 28/28] fix: detect Ollama vision capability from capabilities array Newer Ollama versions (and models like Gemma 4) report multimodal support via a top-level `capabilities` array (e.g. ["vision", "tools"]) rather than via `model_info` keys. The old code only checked model_info, so these models were always detected as non-vision. Now checks capabilities array first, falls back to model_info key scan, then projector_info keys. Also wires supportsToolCalling from the capabilities array. Co-Authored-By: Dishit Karia --- src/stores/remoteModelCapabilities.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/stores/remoteModelCapabilities.ts b/src/stores/remoteModelCapabilities.ts index cbf09ca0..73ab8d71 100644 --- a/src/stores/remoteModelCapabilities.ts +++ b/src/stores/remoteModelCapabilities.ts @@ -43,10 +43,25 @@ function extractOllamaCapabilities(data: Record): RemoteModelIn let contextLength = 4096; let supportsVision = false; + // Newer Ollama versions expose a top-level `capabilities` array (e.g. ["vision", "tools"]). + // Gemma 4 and similar models use this field instead of model_info keys. + let supportsToolCalling: boolean | undefined; + if (Array.isArray(data.capabilities)) { + const caps = data.capabilities as unknown[]; + supportsVision = caps.includes('vision'); + supportsToolCalling = caps.includes('tools'); + } + if (data.model_info && typeof data.model_info === 'object') { const parsed = parseModelInfoKeys(data.model_info as Record); if (parsed.contextLength > 0) contextLength = parsed.contextLength; - supportsVision = parsed.supportsVision; + if (!supportsVision) supportsVision = parsed.supportsVision; + } + + // projector_info is present for multimodal models when capabilities array is missing. + if (!supportsVision && data.projector_info && typeof data.projector_info === 'object') { + const projectorKeys = Object.keys(data.projector_info as Record); + supportsVision = projectorKeys.some(k => k.includes('vision') || k.includes('clip')); } if (contextLength === 4096 && typeof data.parameters === 'string') { @@ -63,7 +78,7 @@ function extractOllamaCapabilities(data: Record): RemoteModelIn /\.Think|\.Thinking|\.IsThinkSet/.test(template) || /^RENDERER\s/m.test(modelfile); - return { contextLength, supportsVision, supportsThinking }; + return { contextLength, supportsVision, supportsToolCalling, supportsThinking }; } /**