From d300dbb0a0f8c466aae29c7f8da46f8338393110 Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 19 Jun 2026 14:33:26 +0530 Subject: [PATCH 01/10] feat: pro feature registry, license seam, and submodule Co-Authored-By: Dishit Karia --- .gitmodules | 3 + .../settings/sectionRegistry.test.ts | 33 +++++++++ .../unit/navigation/screenRegistry.test.ts | 38 ++++++++++ .../unit/services/loadProFeatures.test.ts | 71 +++++++++++++++++++ metro.config.js | 33 +++++++-- pro | 1 + src/bootstrap/loadProFeatures.ts | 25 +++++++ src/bootstrap/proStub.js | 3 + src/components/settings/sectionRegistry.ts | 17 +++++ src/navigation/AppNavigator.tsx | 4 ++ src/navigation/screenRegistry.ts | 20 ++++++ src/screens/SettingsScreen.tsx | 13 +++- tsconfig.json | 2 +- 13 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 .gitmodules 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/loadProFeatures.test.ts create mode 160000 pro 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 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..afa4eda8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "pro"] + path = pro + url = https://github.com/wednesday-solutions/offgrid-pro.git 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/loadProFeatures.test.ts b/__tests__/unit/services/loadProFeatures.test.ts new file mode 100644 index 00000000..03f5e9ab --- /dev/null +++ b/__tests__/unit/services/loadProFeatures.test.ts @@ -0,0 +1,71 @@ +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(), +})); + +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 () => { + 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('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({ + registerToolExtension: expect.any(Function), + registerScreen: expect.any(Function), + registerSettingsSection: expect.any(Function), + }), + ); + }); + + 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/metro.config.js b/metro.config.js index 2a0a21ce..8b4780e9 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,11 +1,30 @@ +const path = require('node:path'); +const fs = require('node: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, 'pro'); +const proStubPath = path.resolve(__dirname, 'src/bootstrap/proStub.js'); +// 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 = { + // 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. + 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/pro b/pro new file mode 160000 index 00000000..ed9b93b8 --- /dev/null +++ b/pro @@ -0,0 +1 @@ +Subproject commit ed9b93b8aa97814a6b52942076031090f4b40b4e diff --git a/src/bootstrap/loadProFeatures.ts b/src/bootstrap/loadProFeatures.ts new file mode 100644 index 00000000..4ff06b14 --- /dev/null +++ b/src/bootstrap/loadProFeatures.ts @@ -0,0 +1,25 @@ +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(isPro?: boolean): Promise { + let pro: any; + try { + pro = require('@offgrid/pro'); + } catch { + return; // free / contributor build: package not installed + } + if (!pro) { + return; // proStub.js returns null — free build via metro extraNodeModules + } + + // 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 + } + + pro.activate({ registerToolExtension, registerScreen, registerSettingsSection }); +} 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/settings/sectionRegistry.ts b/src/components/settings/sectionRegistry.ts new file mode 100644 index 00000000..0b0b2cbf --- /dev/null +++ b/src/components/settings/sectionRegistry.ts @@ -0,0 +1,17 @@ +import type { ComponentType } from 'react'; + +const sections: ComponentType[] = []; + +export function registerSettingsSection(component: ComponentType): void { + if (!sections.includes(component)) { + 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/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 8eb46099..7b4756d5 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'; @@ -48,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); @@ -316,6 +319,9 @@ export const SettingsScreen: React.FC = () => { + {/* Pro feature sections registered at runtime by @offgrid/pro */} + {getSettingsSections().map((Section, i) =>
)} + {/* Reset Onboarding */} @@ -327,9 +333,14 @@ export const SettingsScreen: React.FC = () => { Reset Onboarding Checklist + setShowDebugLogs(true)}> + + Debug Logs + + setShowDebugLogs(false)} /> ); 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 26db7c0d06b92511f4117cbbe263ea603929e91f Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 19 Jun 2026 14:33:32 +0530 Subject: [PATCH 02/10] feat: tool extension seam + MCP wiring into the generation loop Co-Authored-By: Dishit Karia --- .../generation/toolExtensionLoop.test.ts | 178 ++++++++++++++++++ .../unit/services/tools/extensions.test.ts | 58 ++++++ src/components/ChatInput/Popovers.tsx | 31 ++- src/components/ChatInput/index.tsx | 6 + src/screens/ChatScreen/ChatMessageArea.tsx | 16 +- src/screens/ChatScreen/index.tsx | 3 +- .../ChatScreen/useChatGenerationActions.ts | 94 +++++++-- src/services/generationToolLoop.ts | 144 ++++++++------ src/services/tools/extensions.ts | 29 +++ 9 files changed, 482 insertions(+), 77 deletions(-) create mode 100644 __tests__/integration/generation/toolExtensionLoop.test.ts create mode 100644 __tests__/unit/services/tools/extensions.test.ts create mode 100644 src/services/tools/extensions.ts diff --git a/__tests__/integration/generation/toolExtensionLoop.test.ts b/__tests__/integration/generation/toolExtensionLoop.test.ts new file mode 100644 index 00000000..a921cf6f --- /dev/null +++ b/__tests__/integration/generation/toolExtensionLoop.test.ts @@ -0,0 +1,178 @@ +/** + * 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 } 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 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 { useChatStore: realChatStore } = jest.requireActual('../../../src/stores/chatStore'); + const { useAppStore: realAppStore } = jest.requireActual('../../../src/stores/appStore'); + return { + useChatStore: realChatStore, + useAppStore: realAppStore, + 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 { + // createConversation takes a modelId and returns the generated conversation UUID + const conversationId = useChatStore.getState().createConversation('test-model'); + 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'); + }); + }); + + 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 () => { + setupProExtension(); + + 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 = setupProExtension(); + + 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 () => { + setupProExtension(); + + 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 () => { + setupProExtension(`Thinking...${MCP_TOOL_NAME}`, 'Final answer.'); + + 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/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/src/components/ChatInput/Popovers.tsx b/src/components/ChatInput/Popovers.tsx index 2aa4e7ca..278dcab4 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(); @@ -107,9 +110,15 @@ 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 mcpDefaultBg = mcpToolCount > 0 ? colors.primary : colors.textMuted; + const mcpBadgeBg = showMcpWarning ? TOOL_WARNING_COLOR : mcpDefaultBg; return ( @@ -170,6 +179,22 @@ export const QuickSettingsPopover: React.FC = ({ {tools.badgeLabel} + + { + triggerHaptic('impactLight'); + onClose(); + onMcpPress?.(); + }} + > + + MCP + + {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} /> = ({ }) => { 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; @@ -159,6 +171,8 @@ export const ChatMessageArea: React.FC = ({ onToolsPress={() => chat.setShowToolPicker(true)} enabledToolCount={chat.enabledTools.length} showSettingsDot={showSettingsDot} + mcpToolCount={extToolCount} + onMcpPress={handleMcpPress} supportsToolCalling={chat.supportsToolCalling} supportsThinking={chat.supportsThinking} onRepairVision={handleRepairVision} diff --git a/src/screens/ChatScreen/index.tsx b/src/screens/ChatScreen/index.tsx index af48635c..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(() => { @@ -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 9c15769b..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, @@ -17,6 +17,7 @@ import { ragService, retrievalService, } from '../../services'; +import { getToolExtensions } from '../../services/tools/extensions'; import { liteRTService } from '../../services/litert'; import { embeddingService } from '../../services/rag/embedding'; import { useChatStore, useProjectStore, useRemoteServerStore } from '../../stores'; @@ -121,8 +122,7 @@ export async function shouldRouteToImageGenerationFn( deps.setAppIsGeneratingImage(false); } return intent === 'image'; - } catch (error) { - logger.warn('[ChatScreen] Intent classification failed:', error); + } catch { deps.setIsClassifying(false); deps.setAppImageGenerationStatus(null); deps.setAppIsGeneratingImage(false); @@ -168,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( @@ -180,17 +179,16 @@ 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) { 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]; @@ -244,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 { @@ -274,13 +271,20 @@ 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); + + 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][PROMPT] systemPrompt (${systemPrompt.length}ch): "${systemPrompt.substring(0, 800)}"`); const messagesForContext = buildMessagesForContext(targetConversationId, messageText, systemPrompt); await prepareContext(setDebugInfo, systemPrompt, messagesForContext); try { @@ -288,7 +292,35 @@ 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; } @@ -369,8 +401,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 }, ); @@ -378,7 +413,36 @@ 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; } diff --git a/src/services/generationToolLoop.ts b/src/services/generationToolLoop.ts index fb974f23..d1cf087d 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'; @@ -82,7 +83,6 @@ function parseGemmaColonArgs(name: string, colonArgs: string): Recordcall:NAME{...} and */ @@ -146,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); @@ -193,21 +189,20 @@ 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 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 result = 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`); + const ext = exts.find(e => e.canHandle(tc.name)); + const result = ext ? await ext.execute(tc) : await executeToolCall(tc); ctx.callbacks?.onToolCallComplete?.(tc.name, result); const toolResultMsg: Message = { id: `tool-result-${Date.now()}-${tc.id || tc.name}`, role: 'tool', @@ -236,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) => { @@ -249,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()}`, @@ -282,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)); } @@ -333,14 +325,14 @@ 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 }; - 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: '', @@ -349,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; }; } @@ -374,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 }); @@ -393,19 +379,58 @@ 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: [] }; } 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 updated = { ...sys, content: existing + TOOL_BEHAVIOR_GUIDANCE }; + const extHints = getToolExtensions().map(e => e.getSystemPromptHint()).filter(Boolean).join(''); + 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)]; } @@ -419,15 +444,17 @@ 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, ctx?.enabledToolIds) : messages; if (isLiteRTActive() && conversationId) { return callLiteRTForLoop(conversationId, augmentedMessages, { tools, onStream, ctx }); } 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'); } @@ -435,24 +462,38 @@ 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) { + effectiveToolCalls = parsed.toolCalls; + displayResponse = parsed.cleanText; + } + } else if (fullResponse.includes('<|tool_call>') || fullResponse.includes(' 0) { + 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) { + effectiveToolCalls.push(...extCalls); } + displayResponse = ext.stripFromVisibleText(displayResponse); } - return { effectiveToolCalls: toolCalls, displayResponse: fullResponse }; + + return { effectiveToolCalls, displayResponse }; } interface ToolLoopState { @@ -480,9 +521,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?.(); @@ -493,13 +532,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); } @@ -509,14 +546,15 @@ 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: '' }; - 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; } @@ -528,24 +566,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; @@ -561,7 +593,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 = { @@ -577,5 +608,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/tools/extensions.ts b/src/services/tools/extensions.ts new file mode 100644 index 00000000..103931d2 --- /dev/null +++ b/src/services/tools/extensions.ts @@ -0,0 +1,29 @@ +import type { ToolCall, ToolResult, ToolDefinition } from './types'; + +export interface ToolExtension { + id: string; + getSystemPromptHint(): string; + getOpenAISchemas?(): any[]; + // Tools that should surface in the main ToolPickerSheet (toggled via the core + // enabledTools setting), as opposed to MCP which has its own picker. + getToolDefinitions?(): ToolDefinition[]; + 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.some(e => e.id === ext.id)) extensions.push(ext); +} + +export function getToolExtensions(): ToolExtension[] { + return extensions; +} + +export function _clearExtensionsForTesting(): void { + extensions.length = 0; +} From 3d5613bf86bc03eea195e606257b4f03b03f57bc Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 19 Jun 2026 14:33:39 +0530 Subject: [PATCH 03/10] feat: RevenueCat pro license service + unlock modal Co-Authored-By: Dishit Karia --- App.tsx | 26 +- .../onboarding/proBootFlow.test.ts | 115 +++++++ .../rntl/screens/ProDetailScreen.test.tsx | 96 ++++++ .../unit/services/proLicenseService.test.ts | 173 ++++++++++ ios/Podfile.lock | 40 ++- jest.setup.ts | 21 ++ package-lock.json | 114 +++++++ package.json | 6 +- src/components/CustomAlert.tsx | 10 +- .../ProDetailScreen/ProUnlockModal.tsx | 303 ++++++++++++++++++ src/screens/ProDetailScreen/index.tsx | 123 +++++-- src/services/proLicenseService.ts | 225 +++++++++++++ 12 files changed, 1229 insertions(+), 23 deletions(-) create mode 100644 __tests__/integration/onboarding/proBootFlow.test.ts create mode 100644 __tests__/rntl/screens/ProDetailScreen.test.tsx create mode 100644 __tests__/unit/services/proLicenseService.test.ts create mode 100644 src/screens/ProDetailScreen/ProUnlockModal.tsx create mode 100644 src/services/proLicenseService.ts diff --git a/App.tsx b/App.tsx index ef79264c..4064597f 100644 --- a/App.tsx +++ b/App.tsx @@ -12,8 +12,11 @@ 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 { configureRevenueCat, checkProStatus } from './src/services/proLicenseService'; import { hydrateDownloadStore } from './src/services/downloadHydration'; import { useDownloadListeners } from './src/hooks/useDownloads'; import { LockScreen } from './src/screens'; @@ -22,6 +25,9 @@ import { useDownloadStore } from './src/stores/downloadStore'; 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; @@ -166,6 +172,24 @@ function App() { // Initialize RAG database tables ragService.ensureReady().catch((err) => logger.error('Failed to initialize RAG service on startup', 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. + // + // 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(); + const isPro = await checkProStatus(); + + // Load pro features — only activates if the keychain entitlement is set. + // 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); + } + // 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..f0bc0674 --- /dev/null +++ b/__tests__/integration/onboarding/proBootFlow.test.ts @@ -0,0 +1,115 @@ +/** + * 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(), + logIn: jest.fn().mockResolvedValue({ customerInfo: { entitlements: { active: {} }, originalAppUserId: 'anon' }, created: false }), + logOut: jest.fn().mockResolvedValue(undefined), + ENTITLEMENT_VERIFICATION_MODE: { DISABLED: 'DISABLED', INFORMATIONAL: 'INFORMATIONAL' }, + VERIFICATION_RESULT: { NOT_REQUESTED: 'NOT_REQUESTED', VERIFIED: 'VERIFIED', FAILED: 'FAILED', VERIFIED_ON_DEVICE: 'VERIFIED_ON_DEVICE' }, + }, + 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__/rntl/screens/ProDetailScreen.test.tsx b/__tests__/rntl/screens/ProDetailScreen.test.tsx new file mode 100644 index 00000000..92f3374e --- /dev/null +++ b/__tests__/rntl/screens/ProDetailScreen.test.tsx @@ -0,0 +1,96 @@ +/** + * ProDetailScreen Tests + */ + +import React from 'react'; +import { Alert, Linking } from 'react-native'; +import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { useAppStore } from '../../../src/stores/appStore'; + +const mockActivateProByEmail = jest.fn(); +const mockGetWebPurchaseUrl = jest.fn((..._args: unknown[]) => 'https://pay.rev.cat/token/buyer%40example.com?email=buyer%40example.com'); +const mockResetProIdentityForTesting = jest.fn(); + +jest.mock('../../../src/services/proLicenseService', () => ({ + activateProByEmail: (...args: unknown[]) => mockActivateProByEmail(...args), + getWebPurchaseUrl: (...args: unknown[]) => mockGetWebPurchaseUrl(...args), + resetProIdentityForTesting: (...args: unknown[]) => mockResetProIdentityForTesting(...args), +})); + +import { ProDetailScreen } from '../../../src/screens/ProDetailScreen'; + +describe('ProDetailScreen', () => { + let alertSpy: jest.SpyInstance; + let linkingSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + useAppStore.setState({ hasRegisteredPro: false }); + alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + linkingSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(true as never); + }); + + afterEach(() => { + alertSpy.mockRestore(); + linkingSpy.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('opens web checkout with the entered email', async () => { + const { getAllByText, getByText, getByPlaceholderText } = render(); + fireEvent.press(getAllByText('Get Pro')[0]); + fireEvent.changeText(getByPlaceholderText('you@example.com'), 'buyer@example.com'); + fireEvent.press(getByText('Continue to payment')); + await waitFor(() => expect(mockGetWebPurchaseUrl).toHaveBeenCalledWith('buyer@example.com')); + expect(linkingSpy).toHaveBeenCalledWith('https://pay.rev.cat/token/buyer%40example.com?email=buyer%40example.com'); + }); + + it('shows inline success state on a successful verify', async () => { + mockActivateProByEmail.mockResolvedValueOnce(true); + const { getAllByText, getByText, getByPlaceholderText } = render(); + fireEvent.press(getAllByText('Get Pro')[0]); + fireEvent.changeText(getByPlaceholderText('you@example.com'), 'buyer@example.com'); + // Switch to verify mode first + fireEvent.press(getByText('Already paid? Verify email instead')); + fireEvent.press(getByText('Verify and unlock')); + await waitFor(() => expect(mockActivateProByEmail).toHaveBeenCalledWith('buyer@example.com')); + await waitFor(() => expect(getByText('Pro activated')).toBeTruthy()); + }); + + it('shows inline error when no purchase is found for that email', async () => { + mockActivateProByEmail.mockResolvedValueOnce(false); + const { getAllByText, getByText, getByPlaceholderText } = render(); + fireEvent.press(getAllByText('Get Pro')[0]); + fireEvent.changeText(getByPlaceholderText('you@example.com'), 'nope@example.com'); + fireEvent.press(getByText('Already paid? Verify email instead')); + fireEvent.press(getByText('Verify and unlock')); + await waitFor(() => expect(getByText(/No Pro purchase found/)).toBeTruthy()); + }); + + it('shows inline error when email is empty on checkout', async () => { + const { getAllByText, getByText } = render(); + fireEvent.press(getAllByText('Get Pro')[0]); + fireEvent.press(getByText('Continue to payment')); + await waitFor(() => expect(getByText('Enter your email first.')).toBeTruthy()); + }); + + it('renders the Pro Active state when the user already owns Pro', () => { + useAppStore.setState({ hasRegisteredPro: true }); + const { getByText } = render(); + expect(getByText('Pro Active')).toBeTruthy(); + expect(getByText('Pro is active on this account.')).toBeTruthy(); + }); + + it('runs the reset and confirms when the Pro user taps Reset Pro identity', async () => { + useAppStore.setState({ hasRegisteredPro: true }); + mockResetProIdentityForTesting.mockResolvedValueOnce(undefined); + const { getByText } = render(); + fireEvent.press(getByText('Reset Pro identity')); + await waitFor(() => expect(mockResetProIdentityForTesting).toHaveBeenCalledTimes(1)); + expect(alertSpy).toHaveBeenCalledWith('Reset done', expect.any(String)); + }); +}); diff --git a/__tests__/unit/services/proLicenseService.test.ts b/__tests__/unit/services/proLicenseService.test.ts new file mode 100644 index 00000000..4857e66c --- /dev/null +++ b/__tests__/unit/services/proLicenseService.test.ts @@ -0,0 +1,173 @@ +import { + readProFromKeychain, + checkProStatus, + activateProByEmail, + getWebPurchaseUrl, + revalidatePro, + clearProForTesting, + configureRevenueCat, +} from '../../../src/services/proLicenseService'; + +jest.mock('react-native-purchases', () => ({ + __esModule: true, + default: { + setLogLevel: jest.fn(), + configure: jest.fn(), + getCustomerInfo: jest.fn(), + logIn: jest.fn(), + logOut: jest.fn(() => Promise.resolve()), + invalidateCustomerInfoCache: jest.fn(() => Promise.resolve()), + ENTITLEMENT_VERIFICATION_MODE: { DISABLED: 'DISABLED', INFORMATIONAL: 'INFORMATIONAL' }, + VERIFICATION_RESULT: { NOT_REQUESTED: 'NOT_REQUESTED', VERIFIED: 'VERIFIED', FAILED: 'FAILED', VERIFIED_ON_DEVICE: 'VERIFIED_ON_DEVICE' }, + }, + 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' }, +})); + +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 VERIFIED = 'VERIFIED'; +const FAILED = 'FAILED'; + +const proLicense = (email: string | null) => ({ password: JSON.stringify({ isPro: true, email, verifiedAt: 0 }) }); +const customerWith = (verification: string | null) => ({ + entitlements: { active: verification ? { pro: { productIdentifier: 'off_grid_pro_lifetime', verification } } : {} }, + originalAppUserId: 'someone@example.com', +}); + +describe('proLicenseService', () => { + beforeAll(() => { + // configureRevenueCat sets the module-level isConfigured flag the RC-backed + // entry points require. Pin Platform.OS first (its default varies in RN test env). + require('react-native').Platform.OS = 'ios'; + configureRevenueCat(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('configureRevenueCat()', () => { + it('configures the SDK with Trusted Entitlements (informational)', () => { + configureRevenueCat(); + expect(Purchases.configure).toHaveBeenCalledWith( + expect.objectContaining({ entitlementVerificationMode: 'INFORMATIONAL' }), + ); + }); + }); + + describe('readProFromKeychain()', () => { + it('returns false when no entry exists', async () => { + mockGetGenericPassword.mockResolvedValueOnce(false); + expect(await readProFromKeychain()).toBe(false); + }); + + it('returns true when the stored license is pro', async () => { + mockGetGenericPassword.mockResolvedValueOnce(proLicense('a@b.com')); + expect(await readProFromKeychain()).toBe(true); + }); + + it('returns false when the stored license is malformed', async () => { + mockGetGenericPassword.mockResolvedValueOnce({ password: 'not-json' }); + expect(await readProFromKeychain()).toBe(false); + }); + }); + + describe('checkProStatus()', () => { + it('returns the cached value immediately', async () => { + mockGetGenericPassword.mockResolvedValue(proLicense('a@b.com')); + Purchases.logIn.mockResolvedValue({ customerInfo: customerWith(VERIFIED) }); + Purchases.getCustomerInfo.mockResolvedValue(customerWith(VERIFIED)); + expect(await checkProStatus()).toBe(true); + }); + }); + + describe('getWebPurchaseUrl()', () => { + it('puts the normalized email as a path segment and prefills the email param', () => { + const url = getWebPurchaseUrl(' Test@Example.com '); + expect(url).toContain('/test%40example.com'); + expect(url).toContain('?email=test%40example.com'); + expect(url).not.toContain('app_user_id'); + }); + }); + + describe('activateProByEmail()', () => { + it('unlocks Pro when the email has a verified active entitlement', async () => { + Purchases.logIn.mockResolvedValueOnce({ customerInfo: customerWith(VERIFIED) }); + mockSetGenericPassword.mockResolvedValueOnce(true); + expect(await activateProByEmail('Buyer@Example.com')).toBe(true); + expect(Purchases.logIn).toHaveBeenCalledWith('buyer@example.com'); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); + }); + + it('returns false and logs out when the email has no entitlement', async () => { + Purchases.logIn.mockResolvedValueOnce({ customerInfo: customerWith(null) }); + expect(await activateProByEmail('nope@example.com')).toBe(false); + expect(Purchases.logOut).toHaveBeenCalledTimes(1); + expect(mockSetHasRegisteredPro).not.toHaveBeenCalledWith(true); + }); + + it('treats a FAILED verification signature as not Pro (forgery defense)', async () => { + Purchases.logIn.mockResolvedValueOnce({ customerInfo: customerWith(FAILED) }); + expect(await activateProByEmail('forged@example.com')).toBe(false); + expect(Purchases.logOut).toHaveBeenCalledTimes(1); + }); + + it('throws when email is empty', async () => { + await expect(activateProByEmail(' ')).rejects.toThrow('Email is required'); + }); + }); + + describe('revalidatePro() — revocation', () => { + it('locks Pro when the entitlement was revoked (no longer active)', async () => { + mockGetGenericPassword.mockResolvedValue(proLicense('a@b.com')); + Purchases.logIn.mockResolvedValueOnce({ customerInfo: customerWith(null) }); + Purchases.getCustomerInfo.mockResolvedValueOnce(customerWith(null)); + mockSetGenericPassword.mockResolvedValueOnce(true); + await revalidatePro(); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(false); + // wrote isPro=false back to the keychain + const written = JSON.parse(mockSetGenericPassword.mock.calls[0][1]); + expect(written.isPro).toBe(false); + }); + + it('keeps cached state when offline (network error)', async () => { + mockGetGenericPassword.mockResolvedValue(proLicense('a@b.com')); + Purchases.logIn.mockResolvedValueOnce({ customerInfo: customerWith(VERIFIED) }); + Purchases.getCustomerInfo.mockRejectedValueOnce(new Error('network')); + await revalidatePro(); + expect(mockSetGenericPassword).not.toHaveBeenCalled(); + expect(mockSetHasRegisteredPro).not.toHaveBeenCalled(); + }); + + it('keeps Pro active when the entitlement is still valid', async () => { + mockGetGenericPassword.mockResolvedValue(proLicense('a@b.com')); + Purchases.logIn.mockResolvedValueOnce({ customerInfo: customerWith(VERIFIED) }); + Purchases.getCustomerInfo.mockResolvedValueOnce(customerWith(VERIFIED)); + mockSetGenericPassword.mockResolvedValueOnce(true); + await revalidatePro(); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(true); + }); + }); + + describe('clearProForTesting()', () => { + it('resets the keychain and clears the store flag', async () => { + mockResetGenericPassword.mockResolvedValueOnce(true); + await clearProForTesting(); + expect(mockResetGenericPassword).toHaveBeenCalledTimes(1); + expect(mockSetHasRegisteredPro).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cac5f375..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 @@ -2797,6 +2802,11 @@ 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): - boost - DoubleConversion @@ -2885,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 @@ -3368,11 +3384,14 @@ 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`) - 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`) @@ -3387,6 +3406,10 @@ DEPENDENCIES: SPEC REPOS: trunk: - lottie-ios + - PurchasesHybridCommon + - PurchasesHybridCommonUI + - RevenueCat + - RevenueCatUI - SocketRocket - SSZipArchive @@ -3566,6 +3589,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: @@ -3576,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: @@ -3603,11 +3632,13 @@ SPEC CHECKSUMS: FBLazyVector: 309703e71d3f2f1ed7dc7889d58309c9d77a95a4 fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - hermes-engine: 4d40ce008f57c9348a66614a3167581aa861e379 + hermes-engine: 8c6be38f94b3bf8b864981980e64e55f08e467ec llama-rn: 796fa53f37f89e2c77cd6c462ad1172ee96d4c80 lottie-ios: a881093fab623c467d3bce374367755c272bdd59 lottie-react-native: 691b8363e8c591fb78a78254ff2517258891456b op-sqlite: bafff369cecaee4fe65c89eec47deaba26f2db95 + PurchasesHybridCommon: 825e4e748b62c919bc4cb4032b0d1e452409bd74 + PurchasesHybridCommonUI: 536abdfc64b82adcbb9ba352630722a4a1270571 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: a41bbdd9af30bf2e5715796b313e44ec43eefff1 RCTRequired: 7be34aabb0b77c3cefe644528df0fa0afad4e4d0 @@ -3684,11 +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 @@ -3701,6 +3737,6 @@ SPEC CHECKSUMS: whisper-rn: 7566faf9b7d78e39ab9fc634cb90fdee81177793 Yoga: 5456bb010373068fc92221140921b09d126b116e -PODFILE CHECKSUM: 31818a1f7d1207c486dba2e42df373cf65ace073 +PODFILE CHECKSUM: 88f0d247acf3f66fc1ac0eb9612471f79e7f0cce COCOAPODS: 1.16.2 diff --git a/jest.setup.ts b/jest.setup.ts index 15d0f8cb..24779a46 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -216,6 +216,27 @@ 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: {} } } })), + logIn: jest.fn(() => Promise.resolve({ customerInfo: { entitlements: { active: {} }, originalAppUserId: 'anon' }, created: false })), + invalidateCustomerInfoCache: jest.fn(() => Promise.resolve()), + logOut: jest.fn(() => Promise.resolve()), + ENTITLEMENT_VERIFICATION_MODE: { DISABLED: 'DISABLED', INFORMATIONAL: 'INFORMATIONAL' }, + VERIFICATION_RESULT: { NOT_REQUESTED: 'NOT_REQUESTED', VERIFIED: 'VERIFIED', FAILED: 'FAILED', VERIFIED_ON_DEVICE: 'VERIFIED_ON_DEVICE' }, + }, + 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 9f919a7a..1feb2e88 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,14 +32,19 @@ "patch-package": "^8.0.1", "react": "19.2.0", "react-native": "0.83.1", + "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", "react-native-haptic-feedback": "^2.3.3", "react-native-image-picker": "^8.2.1", + "react-native-inappbrowser-reborn": "^3.7.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-restart": "^0.0.28", "react-native-safe-area-context": "^5.6.2", "react-native-screens": "^4.20.0", "react-native-spotlight-tour": "^4.0.0", @@ -4054,6 +4059,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", @@ -11558,6 +11584,15 @@ "node": ">=8" } }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -12335,6 +12370,15 @@ } } }, + "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", @@ -12409,6 +12453,20 @@ "react-native": "*" } }, + "node_modules/react-native-inappbrowser-reborn": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/react-native-inappbrowser-reborn/-/react-native-inappbrowser-reborn-3.7.1.tgz", + "integrity": "sha512-4HSomJzElAqzJs5ORQmYY79fBQ/33WRQ1tScyHaREfvpLpZfoMX1moopGaytXGmakmYmR9RaYWOYZaZr9bZoOw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "opencollective-postinstall": "^2.0.3" + }, + "peerDependencies": { + "react-native": ">=0.56" + } + }, "node_modules/react-native-is-edge-to-edge": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", @@ -12442,6 +12500,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", @@ -12478,6 +12582,16 @@ "react-native": ">=0.44.1" } }, + "node_modules/react-native-restart": { + "version": "0.0.28", + "resolved": "https://registry.npmjs.org/react-native-restart/-/react-native-restart-0.0.28.tgz", + "integrity": "sha512-FUGr24ZDCC2AwuhDcK8SRoDxqCyFMSlhJaR4u5nSEAcAkn4S26VxrZSpiBdI1CxC6rnbaV+vh9kdxv8YIS+pdw==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-safe-area-context": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", diff --git a/package.json b/package.json index c0557f25..7b10edea 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", @@ -43,13 +43,17 @@ "patch-package": "^8.0.1", "react": "19.2.0", "react-native": "0.83.1", + "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", "react-native-haptic-feedback": "^2.3.3", "react-native-image-picker": "^8.2.1", + "react-native-inappbrowser-reborn": "^3.7.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/components/CustomAlert.tsx b/src/components/CustomAlert.tsx index 1074ae8e..4d637d0c 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) => ( ({ 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/ProDetailScreen/ProUnlockModal.tsx b/src/screens/ProDetailScreen/ProUnlockModal.tsx new file mode 100644 index 00000000..060cba78 --- /dev/null +++ b/src/screens/ProDetailScreen/ProUnlockModal.tsx @@ -0,0 +1,303 @@ +import React, { useState } from 'react'; +import { + View, + Text, + TouchableOpacity, + Modal, + TextInput, + Linking, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { useTheme, useThemedStyles } from '../../theme'; +import type { ThemeColors, ThemeShadows } from '../../theme'; +import { SPACING, TYPOGRAPHY } from '../../constants'; +import { activateProByEmail, getWebPurchaseUrl } from '../../services/proLicenseService'; + +type Props = { + visible: boolean; + onClose: () => void; + onUnlocked: () => void; +}; + +// Two modes: pay (default) or verify (already paid). +// One primary button, one text toggle. No competing button rows. +type Mode = 'pay' | 'verify'; +type ErrorMsg = string | null; + +export const ProUnlockModal: React.FC = ({ visible, onClose, onUnlocked }) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + + const [email, setEmail] = useState(''); + const [mode, setMode] = useState('pay'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const reset = () => { + setEmail(''); + setMode('pay'); + setLoading(false); + setError(null); + setSuccess(false); + }; + + const close = () => { + if (loading || success) return; + reset(); + onClose(); + }; + + const clearError = () => { if (error) setError(null); }; + + const handlePrimary = async () => { + const trimmed = email.trim(); + if (!trimmed) { + setError('Enter your email first.'); + return; + } + + if (mode === 'pay') { + try { + await Linking.openURL(getWebPurchaseUrl(trimmed)); + } catch { + setError('Could not open checkout. Please try again.'); + } + return; + } + + // verify mode + setLoading(true); + setError(null); + try { + const unlocked = await activateProByEmail(trimmed); + if (unlocked) { + setSuccess(true); + onUnlocked(); + } else { + setError('No Pro purchase found for that email. Check the address and try again.'); + } + } catch { + setError('Verification failed. Check your connection and try again.'); + } finally { + setLoading(false); + } + }; + + if (success) { + return ( + {}}> + + + + + + Pro activated + Close and reopen the app to load your Pro features. + + + + ); + } + + const isPay = mode === 'pay'; + + return ( + + + + + {/* Close X */} + + + + + {/* Header */} + Unlock Off Grid Pro + + {isPay + ? 'Enter your email to pay. One-time $50, no subscription.' + : 'Enter the email you used when you paid.'} + + + {/* Email input */} + { setEmail(t); clearError(); }} + editable={!loading} + /> + + {/* Inline error */} + {error ? ( + {error} + ) : null} + + {/* Primary CTA */} + + {isPay ? ( + <> + Continue to payment + + $50 + + + ) : ( + + {loading ? 'Verifying...' : 'Verify and unlock'} + + )} + + + {/* Mode toggle — plain text, no button chrome */} + { setMode(isPay ? 'verify' : 'pay'); setError(null); }} + disabled={loading} + > + + {isPay ? 'Already paid? Verify email instead' : 'Not paid yet? Back to checkout'} + + + + + + + + ); +}; + +const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.6)', + justifyContent: 'center' as const, + paddingHorizontal: SPACING.xl, + }, + card: { + backgroundColor: colors.surface, + borderRadius: 20, + paddingHorizontal: SPACING.xl, + paddingTop: SPACING.md, + paddingBottom: SPACING.xl, + ...shadows.small, + }, + + closeBtn: { + alignSelf: 'flex-end' as const, + padding: SPACING.sm, + marginBottom: SPACING.xs, + }, + + title: { + ...TYPOGRAPHY.h2, + color: colors.text, + marginBottom: SPACING.xs, + }, + subtitle: { + ...TYPOGRAPHY.bodySmall, + color: colors.textSecondary, + lineHeight: 20, + marginBottom: SPACING.lg, + }, + + input: { + ...TYPOGRAPHY.body, + color: colors.text, + backgroundColor: colors.surfaceLight, + borderRadius: 12, + paddingHorizontal: SPACING.md, + paddingVertical: SPACING.md, + marginBottom: SPACING.xs, + }, + + errorText: { + fontSize: 13, + fontWeight: '400' as const, + color: '#E05252', + marginBottom: SPACING.md, + paddingHorizontal: SPACING.xs, + lineHeight: 18, + }, + + primaryBtn: { + backgroundColor: colors.primary, + borderRadius: 14, + paddingVertical: SPACING.lg, + paddingHorizontal: SPACING.lg, + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + gap: SPACING.sm, + marginTop: SPACING.sm, + marginBottom: SPACING.lg, + }, + primaryBtnText: { + fontSize: 16, + fontWeight: '600' as const, + color: '#FFFFFF', + letterSpacing: 0.2, + }, + pricePill: { + backgroundColor: 'rgba(0,0,0,0.18)', + borderRadius: 20, + paddingHorizontal: SPACING.sm, + paddingVertical: 3, + }, + priceText: { + fontSize: 13, + fontWeight: '600' as const, + color: '#FFFFFF', + }, + + toggleRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + gap: SPACING.xs, + paddingVertical: SPACING.sm, + marginBottom: SPACING.xs, + }, + toggleText: { + ...TYPOGRAPHY.bodySmall, + color: colors.text, + }, + + disabled: { + opacity: 0.5, + }, + + // Success state + successIconWrap: { + width: 52, + height: 52, + borderRadius: 26, + borderWidth: 1.5, + borderColor: colors.primary, + alignItems: 'center' as const, + justifyContent: 'center' as const, + alignSelf: 'center' as const, + marginBottom: SPACING.lg, + }, + successTitle: { + ...TYPOGRAPHY.h2, + color: colors.text, + textAlign: 'center' as const, + marginBottom: SPACING.sm, + }, + successSub: { + ...TYPOGRAPHY.body, + color: colors.textSecondary, + textAlign: 'center' as const, + }, +}); diff --git a/src/screens/ProDetailScreen/index.tsx b/src/screens/ProDetailScreen/index.tsx index b3e06444..983e2a80 100644 --- a/src/screens/ProDetailScreen/index.tsx +++ b/src/screens/ProDetailScreen/index.tsx @@ -1,13 +1,14 @@ -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 { resetProIdentityForTesting } from '../../services/proLicenseService'; +import { ProUnlockModal } from './ProUnlockModal'; const INTEGRATIONS = [ { icon: 'mic', title: 'Voice', desc: 'Local speech-to-text\nprocessing.' }, @@ -16,16 +17,17 @@ const INTEGRATIONS = [ { icon: 'message-square', title: 'Messaging', desc: 'Slack,\nTelegram & more.' }, ]; - export const ProDetailScreen: React.FC = () => { const { colors, isDark } = useTheme(); const styles = useThemedStyles(createStyles); - const setHasRegisteredPro = useAppStore((s) => s.setHasRegisteredPro); + const hasRegisteredPro = useAppStore((s) => s.hasRegisteredPro); + const [emailModalVisible, setEmailModalVisible] = useState(false); + + const openEmailModal = () => setEmailModalVisible(true); - const handleCTA = () => { - setHasRegisteredPro(true); - Linking.openURL(PRO_URL); - }; + // The modal handles the restart via RNRestart after showing the success state. + // Nothing to do here — onUnlocked is a signal that purchase completed. + const handleUnlocked = () => {}; return ( @@ -49,9 +51,19 @@ export const ProDetailScreen: React.FC = () => { Off Grid Pro - - Get Pro - + {hasRegisteredPro ? ( + + + Pro Active + + ) : ( + + Get Pro + + )} {/* Hero */} @@ -134,12 +146,48 @@ export const ProDetailScreen: React.FC = () => { - {/* CTA */} - - I am in 🔥 - + {/* CTA / Pro active */} + {hasRegisteredPro ? ( + <> + + + Pro is active on this account. + + { + await resetProIdentityForTesting(); + Alert.alert('Reset done', 'RC identity cleared. Restart the app to test the purchase flow again.'); + }} + > + Reset Pro identity + + + ) : ( + <> + + Get Pro + + + + Already paid? Unlock with email + + + )} + + setEmailModalVisible(false)} + onUnlocked={handleUnlocked} + /> ); }; @@ -183,6 +231,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 +406,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 new file mode 100644 index 00000000..2e527cc2 --- /dev/null +++ b/src/services/proLicenseService.ts @@ -0,0 +1,225 @@ +import { Platform } from 'react-native'; +import Purchases, { LOG_LEVEL } from 'react-native-purchases'; +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, + RC_WEB_PURCHASE_URL, + USE_RC_TEST_STORE, +} from '../config/revenueCatKeys'; + +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 configure is skipped and this stays false, so the RC-backed +// entry points below no-op or fail loudly instead of throwing native errors. +let isConfigured = false; + +// Identity model: there is no login. The user's email is used as the RevenueCat +// App User ID. They pay on the web (RC Web Billing) with that email, then enter +// the same email in the app to unlock Pro. We cache { isPro, email } locally and +// re-validate against RC when online so a revoked entitlement locks the app. +type ProLicense = { isPro: boolean; email: string | null; verifiedAt: number }; + +function setProInStore(isPro: boolean): void { + const { useAppStore } = require('../stores/appStore'); + useAppStore.getState().setHasRegisteredPro(isPro); +} + +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; + 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)}...`); + // Trusted Entitlements (informational): RC cryptographically signs the + // entitlement payload and the SDK verifies it on-device. We treat a FAILED + // signature as not-Pro (forgery/MITM defense, see hasVerifiedPro). It has no + // performance or behaviour cost otherwise. + Purchases.configure({ + apiKey, + entitlementVerificationMode: Purchases.ENTITLEMENT_VERIFICATION_MODE.INFORMATIONAL, + }); + isConfigured = true; + logger.log('[RC] configure: SDK configured OK'); + } catch (e: any) { + logger.error(`[RC] configure FAILED: ${e?.message ?? e}`); + throw e; + } +} + +// An entitlement counts as Pro only when it is active AND its Trusted-Entitlements +// signature did not fail. We allow NOT_REQUESTED / VERIFIED / VERIFIED_ON_DEVICE +// (legitimate cached or unverified states) and reject only FAILED, so we never +// false-lock a paying user while still blocking forged entitlement payloads. +function hasVerifiedPro(customerInfo: any): boolean { + const ent = customerInfo?.entitlements?.active?.[ENTITLEMENT_ID]; + if (!ent) return false; + if (ent.verification === Purchases.VERIFICATION_RESULT.FAILED) { + logger.error('[RC] entitlement present but verification FAILED — treating as not Pro'); + return false; + } + return true; +} + +async function writeLicense(isPro: boolean, email: string | null): Promise { + const license: ProLicense = { isPro, email, verifiedAt: Date.now() }; + logger.log(`[RC] writeLicense isPro=${isPro} email=${email ?? 'none'}`); + 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 failure to the user. RC still holds the entitlement and the + // next re-validate re-writes the cache, so log and continue. + const message = e instanceof Error ? e.message : String(e); + logger.error(`[RC] writeLicense failed to persist to keychain: ${message}`); + } +} + +async function readProLicense(): Promise { + try { + const result = await Keychain.getGenericPassword({ service: KEYCHAIN_SERVICE }); + if (!result) { + return { isPro: false, email: null, verifiedAt: 0 }; + } + const license: ProLicense = JSON.parse(result.password); + return { + isPro: license.isPro ?? false, + email: license.email ?? null, + verifiedAt: license.verifiedAt ?? 0, + }; + } catch (e: any) { + logger.error(`[RC] readProLicense error: ${e?.message ?? e}`); + return { isPro: false, email: null, verifiedAt: 0 }; + } +} + +export async function readProFromKeychain(): Promise { + const { isPro } = await readProLicense(); + return isPro; +} + +export async function checkProStatus(): Promise { + const { isPro } = await readProLicense(); + logger.log(`[RC] checkProStatus: cached=${isPro}, firing background revalidate`); + revalidatePro().catch(() => {}); + return isPro; +} + +// Re-checks the stored email's entitlement with RevenueCat when online. This is +// the revocation path: if Pro is revoked in the RC dashboard, the next online +// launch flips the cached flag to false and locks the app. Network errors are +// swallowed so offline users keep their cached access (grace period). +export async function revalidatePro(): Promise { + if (!isConfigured) { + logger.log('[RC] revalidatePro skipped: SDK not configured'); + return; + } + const { email } = await readProLicense(); + try { + if (email) { + await Purchases.logIn(email); + } + await Purchases.invalidateCustomerInfoCache(); + const info = await Purchases.getCustomerInfo(); + const isPro = hasVerifiedPro(info); + logger.log(`[RC] revalidatePro: email=${email ?? 'none'} isPro=${isPro} active=[${Object.keys(info.entitlements.active).join(', ') || 'none'}]`); + await writeLicense(isPro, email); + setProInStore(isPro); + } catch (e: any) { + // Offline / transient failure — keep the cached state, do NOT lock the user. + logger.error(`[RC] revalidatePro error (keeping cached state): ${e?.message ?? e} (code=${e?.code ?? 'none'} underlying=${e?.underlyingErrorMessage ?? 'none'})`); + } +} + +// Builds the RevenueCat Web Purchase Link URL. RC identifies the customer by +// the App User ID, which is a path segment (not a query parameter). We use the +// email as the App User ID so the Stripe purchase ties to the same identity the +// app uses when calling logIn(email). The ?email= param prefills the email +// field on the checkout page. +// https://pay.rev.cat//?email= +export function getWebPurchaseUrl(email: string): string { + const normalized = email.trim().toLowerCase(); + const encoded = encodeURIComponent(normalized); + const base = RC_WEB_PURCHASE_URL.endsWith('/') ? RC_WEB_PURCHASE_URL : `${RC_WEB_PURCHASE_URL}/`; + return `${base}${encoded}?email=${encoded}`; +} + +// Unlocks Pro by logging in as the email (the RC App User ID) and checking the +// entitlement. Used both after a web purchase and to "restore" on a new device. +export async function activateProByEmail(email: string): Promise { + if (!isConfigured) { + logger.error('[RC] activateProByEmail ABORT: SDK not configured'); + throw new Error('RevenueCat is not configured'); + } + const normalized = email.trim().toLowerCase(); + if (!normalized) { + throw new Error('Email is required'); + } + logger.log(`[RC] activateProByEmail: calling logIn for ${normalized}`); + let customerInfo: any; + try { + const result = await Purchases.logIn(normalized); + customerInfo = result.customerInfo; + logger.log(`[RC] activateProByEmail: logIn OK — appUserId=${customerInfo?.originalAppUserId}`); + } catch (e: any) { + logger.error(`[RC] activateProByEmail: logIn FAILED — ${e?.message ?? e} (code=${e?.code ?? 'none'} underlying=${e?.underlyingErrorMessage ?? 'none'})`); + throw e; + } + const isPro = hasVerifiedPro(customerInfo); + logger.log(`[RC] activateProByEmail: isPro=${isPro} active=[${Object.keys(customerInfo.entitlements.active).join(', ') || 'none'}] verification=${customerInfo?.entitlements?.active?.[ENTITLEMENT_ID]?.verification ?? 'n/a'}`); + if (isPro) { + await writeLicense(true, normalized); + setProInStore(true); + return true; + } + // No entitlement for that email (wrong email, typo, or no purchase). Log back + // out so the device is not stranded on an empty identity, and don't cache it. + try { + await Purchases.logOut(); + } catch (e: any) { + logger.error(`[RC] activateProByEmail: logOut after miss failed: ${e?.message ?? e}`); + } + return false; +} + +export async function clearProForTesting(): Promise { + await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE }); + setProInStore(false); +} + +export async function resetProIdentityForTesting(): Promise { + if (!isConfigured) { + logger.log('[RC] resetProIdentityForTesting skipped: SDK not configured'); + return; + } + logger.log('[RC] resetProIdentityForTesting: start'); + 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}`); + if (!isAnonymous) { + await Purchases.logOut(); + } else { + logger.log('[RC] resetProIdentityForTesting: anonymous user — skipping logOut'); + } + } catch (e: any) { + logger.error(`[RC] resetProIdentityForTesting: ${e?.message ?? e} — continuing`); + } + await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE }); + setProInStore(false); + logger.log('[RC] resetProIdentityForTesting: done'); +} From 99718d6203749dafc63bf80cbaf7c0ecf5cb98ea Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 19 Jun 2026 14:33:43 +0530 Subject: [PATCH 04/10] feat: in-app debug log viewer Co-Authored-By: Dishit Karia --- __tests__/unit/stores/debugLogsStore.test.ts | 68 -------------------- src/components/DebugLogsScreen/index.tsx | 8 +-- src/stores/debugLogsStore.ts | 33 +++------- src/utils/logger.ts | 12 ++++ 4 files changed, 23 insertions(+), 98 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(); - }); - }); -}); 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/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 = { From fcbc9c99f541620d698f88a5fcf377128b0a1cd9 Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 19 Jun 2026 14:33:48 +0530 Subject: [PATCH 05/10] feat: pro-gated email/calendar tools via ToolExtension Co-Authored-By: Dishit Karia --- __tests__/unit/services/toolHandlers.test.ts | 30 ++++ .../tools/EmailCalendarExtension.test.ts | 163 ++++++++++++++++++ .../unit/services/tools/handlers.test.ts | 1 + .../unit/services/tools/registry.test.ts | 2 + android/app/src/main/AndroidManifest.xml | 4 + ios/OffgridMobile/Info.plist | 4 + jest.config.js | 6 +- src/components/ToolPickerSheet.tsx | 24 ++- 8 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 __tests__/unit/services/tools/EmailCalendarExtension.test.ts diff --git a/__tests__/unit/services/toolHandlers.test.ts b/__tests__/unit/services/toolHandlers.test.ts index 2bdadef0..1a2ab8b3 100644 --- a/__tests__/unit/services/toolHandlers.test.ts +++ b/__tests__/unit/services/toolHandlers.test.ts @@ -275,3 +275,33 @@ describe('search_knowledge_base handler', () => { expect(typeof result.durationMs).toBe('number'); }); }); + +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(); + }); +}); diff --git a/__tests__/unit/services/tools/EmailCalendarExtension.test.ts b/__tests__/unit/services/tools/EmailCalendarExtension.test.ts new file mode 100644 index 00000000..9baa2276 --- /dev/null +++ b/__tests__/unit/services/tools/EmailCalendarExtension.test.ts @@ -0,0 +1,163 @@ +/** + * EmailCalendarExtension Unit Tests + * + * The email + calendar tools are pro-gated and implemented in the pro package + * as a ToolExtension. These tests cover the tool definitions, enabled-state + * filtering (read from the core enabledTools setting), and execution against a + * mocked calendar native module and mailto link. + */ + +import { Linking } from 'react-native'; +import type { ToolCall } from '../../../../src/services/tools/types'; + +let mockEnabledTools: string[] = []; +jest.mock('@offgrid/core/stores', () => ({ + useAppStore: { getState: () => ({ settings: { enabledTools: mockEnabledTools } }) }, +})); + +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), + }, +})); + +// Imported after the mocks above are registered (jest hoists jest.mock). +import { EmailCalendarExtension } from '../../../../pro/tools/EmailCalendarExtension'; + +const mockOpenURL = jest.spyOn(Linking, 'openURL'); + +function call(name: string, args: Record = {}): ToolCall { + return { id: `c-${name}`, name, arguments: args }; +} + +describe('EmailCalendarExtension', () => { + beforeEach(() => { + mockEnabledTools = []; + mockOpenURL.mockReset().mockResolvedValue(undefined as never); + mockSaveEvent.mockReset().mockResolvedValue('evt-1'); + mockRequestPermissions.mockReset().mockResolvedValue('authorized'); + mockFetchAllEvents.mockReset().mockResolvedValue([]); + }); + + describe('definitions and gating', () => { + it('advertises the three tools to the main picker', () => { + const ids = EmailCalendarExtension.getToolDefinitions!().map(t => t.id); + expect(ids).toEqual(['send_email', 'create_calendar_event', 'read_calendar_events']); + }); + + it('canHandle matches only its own tools', () => { + expect(EmailCalendarExtension.canHandle('send_email')).toBe(true); + expect(EmailCalendarExtension.canHandle('create_calendar_event')).toBe(true); + expect(EmailCalendarExtension.canHandle('web_search')).toBe(false); + }); + + it('exposes schemas only for the enabled subset', () => { + mockEnabledTools = ['send_email']; + const schemas = EmailCalendarExtension.getOpenAISchemas!(); + expect(schemas.map((s: any) => s.function.name)).toEqual(['send_email']); + }); + + it('returns no schemas or hint when nothing is enabled', () => { + expect(EmailCalendarExtension.getOpenAISchemas!()).toEqual([]); + expect(EmailCalendarExtension.getSystemPromptHint()).toBe(''); + }); + + it('hint lists only enabled tools', () => { + mockEnabledTools = ['create_calendar_event']; + const hint = EmailCalendarExtension.getSystemPromptHint(); + expect(hint).toContain('create_calendar_event'); + expect(hint).not.toContain('send_email'); + }); + + it('reports 0 from enabledToolCount to avoid double counting', () => { + mockEnabledTools = ['send_email', 'create_calendar_event']; + expect(EmailCalendarExtension.enabledToolCount()).toBe(0); + }); + + it('does not parse or strip text (core handles the standard format)', () => { + expect(EmailCalendarExtension.parseToolCalls('hello')).toEqual([]); + expect(EmailCalendarExtension.stripFromVisibleText('hello')).toBe('hello'); + }); + }); + + describe('execute: send_email', () => { + it('opens the mail app and reports the recipient', async () => { + const res = await EmailCalendarExtension.execute( + call('send_email', { to: 'a@b.com', subject: 'Hi', body: 'Yo' }), + ); + expect(res.error).toBeUndefined(); + expect(mockOpenURL).toHaveBeenCalledWith(expect.stringContaining('mailto:')); + expect(res.content).toContain('a@b.com'); + }); + + it('errors when the to address is missing', async () => { + const res = await EmailCalendarExtension.execute(call('send_email', {})); + expect(res.error).toContain('Missing required parameter: to'); + }); + + it('errors when the mail app cannot be opened', async () => { + mockOpenURL.mockRejectedValue(new Error('no app')); + const res = await EmailCalendarExtension.execute(call('send_email', { to: 'a@b.com' })); + expect(res.error).toContain('mail app'); + }); + }); + + describe('execute: calendar', () => { + it('creates an event after requesting write permission', async () => { + const res = await EmailCalendarExtension.execute( + call('create_calendar_event', { + title: 'Sync', + start_date: '2026-07-01T10:00:00.000Z', + end_date: '2026-07-01T11:00:00.000Z', + }), + ); + expect(res.error).toBeUndefined(); + expect(mockRequestPermissions).toHaveBeenCalledWith(false); + expect(mockSaveEvent).toHaveBeenCalledWith( + 'Sync', + expect.objectContaining({ startDate: '2026-07-01T10:00:00.000Z' }), + ); + }); + + it('errors when calendar permission is denied', async () => { + mockRequestPermissions.mockResolvedValue('denied'); + const res = await EmailCalendarExtension.execute( + call('create_calendar_event', { title: 'Sync', start_date: '2026-07-01T10:00:00.000Z' }), + ); + expect(res.error).toBe('Calendar permission denied'); + expect(mockSaveEvent).not.toHaveBeenCalled(); + }); + + it('errors on an invalid start_date', async () => { + const res = await EmailCalendarExtension.execute( + call('create_calendar_event', { title: 'X', start_date: 'nope' }), + ); + expect(res.error).toContain('Invalid start_date'); + }); + + it('reads and formats events', async () => { + mockFetchAllEvents.mockResolvedValue([ + { title: 'Lunch', startDate: '2026-07-01T12:00:00.000Z', endDate: '2026-07-01T13:00:00.000Z', location: 'Cafe' }, + ]); + const res = await EmailCalendarExtension.execute(call('read_calendar_events', {})); + expect(res.content).toContain('Lunch'); + expect(res.content).toContain('Cafe'); + }); + + it('reports when no events are found', async () => { + const res = await EmailCalendarExtension.execute(call('read_calendar_events', {})); + expect(res.content).toContain('No calendar events found'); + }); + }); + + it('returns an Unknown tool error for names it does not own', async () => { + const res = await EmailCalendarExtension.execute(call('web_search', { query: 'x' })); + expect(res.error).toContain('Unknown tool'); + }); +}); diff --git a/__tests__/unit/services/tools/handlers.test.ts b/__tests__/unit/services/tools/handlers.test.ts index 74e14143..d917d8ba 100644 --- a/__tests__/unit/services/tools/handlers.test.ts +++ b/__tests__/unit/services/tools/handlers.test.ts @@ -561,4 +561,5 @@ describe('Tool Handlers', () => { expect(result.error).toBeUndefined(); }); }); + }); diff --git a/__tests__/unit/services/tools/registry.test.ts b/__tests__/unit/services/tools/registry.test.ts index 94991672..767cc679 100644 --- a/__tests__/unit/services/tools/registry.test.ts +++ b/__tests__/unit/services/tools/registry.test.ts @@ -17,6 +17,8 @@ describe('Tool Registry', () => { // ======================================================================== describe('AVAILABLE_TOOLS', () => { it('has exactly 6 tools with correct IDs', () => { + // Email + calendar tools are pro-gated and live in the pro package + // (EmailCalendarExtension), so they are not part of the core registry. expect(AVAILABLE_TOOLS).toHaveLength(6); const ids = AVAILABLE_TOOLS.map(t => t.id); 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 @@ + + + + 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. + 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/jest.config.js b/jest.config.js index c5fa22cc..51eddd26 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,7 +3,11 @@ module.exports = { setupFilesAfterEnv: ['/jest.setup.ts'], testMatch: ['**/__tests__/**/*.test.ts', '**/__tests__/**/*.test.tsx'], testPathIgnorePatterns: ['/node_modules/', '/android/', '/ios/', '/e2e/', 'App.test.tsx'], - moduleNameMapper: { '^@/(.*)$': '/src/$1' }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + // Mirrors the metro alias so tests can import pro modules that reference core. + '^@offgrid/core/(.*)$': '/src/$1', + }, transformIgnorePatterns: ['node_modules/(?!(react-native|@react-native|@react-navigation|react-native-.*|@react-native-.*|moti|@motify|@gorhom|@shopify|@ronradtke|@op-engineering)/)',], testEnvironment: 'node', clearMocks: true, diff --git a/src/components/ToolPickerSheet.tsx b/src/components/ToolPickerSheet.tsx index c32a762f..f9b933fe 100644 --- a/src/components/ToolPickerSheet.tsx +++ b/src/components/ToolPickerSheet.tsx @@ -1,10 +1,11 @@ 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'; import { FONTS, TYPOGRAPHY, SPACING } from '../constants'; import { AVAILABLE_TOOLS } from '../services/tools'; +import { getToolExtensions } from '../services/tools/extensions'; import { useAppStore } from '../stores'; import type { ThemeColors, ThemeShadows } from '../theme'; @@ -27,6 +28,14 @@ export const ToolPickerSheet: React.FC = ({ const styles = useThemedStyles(createStyles); const { toolCountHintDismissed, setToolCountHintDismissed } = useAppStore(); + // Pro extensions (e.g. email/calendar) advertise tools that belong in the main + // picker and toggle through the same enabledTools array. MCP is excluded here + // because it has its own dedicated picker. Dedupe by id so a tool never doubles. + const extensionTools = getToolExtensions().flatMap(e => e.getToolDefinitions?.() ?? []); + const availableTools = [...AVAILABLE_TOOLS, ...extensionTools].filter( + (tool, index, all) => all.findIndex(t => t.id === tool.id) === index, + ); + const showHint = enabledTools.length > 3 && !toolCountHintDismissed; return ( @@ -36,7 +45,7 @@ export const ToolPickerSheet: React.FC = ({ enableDynamicSizing title="Tools" > - + {showHint && ( @@ -50,7 +59,7 @@ export const ToolPickerSheet: React.FC = ({ )} - {AVAILABLE_TOOLS.map(tool => { + {availableTools.map(tool => { const isEnabled = enabledTools.includes(tool.id); return ( @@ -78,15 +87,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 66ff0c44d90608d037f9880abe89ba942bf28671 Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 19 Jun 2026 14:36:08 +0530 Subject: [PATCH 06/10] feat: add RevenueCat config key placeholders Public placeholder config so the app compiles; real iOS/Android keys stay in gitignored revenueCatKeys.local.ts and are never committed. Co-Authored-By: Dishit Karia --- src/config/revenueCatKeys.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/config/revenueCatKeys.ts 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; From 18ee7f1d45a46e5578716610ce8cb71a40225441 Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 19 Jun 2026 14:44:01 +0530 Subject: [PATCH 07/10] fix: map @offgrid/core/* in tsconfig so tsc resolves the pro extension The EmailCalendarExtension test imports the pro module by path, pulling it into tsc's program past the pro/** exclude. Without a paths mapping for @offgrid/core/* (which jest.config.js already has), tsc could not resolve the pro module's core type imports, cascading into TS18046/TS7006 errors. Mirror the jest moduleNameMapper so tsc and jest agree. Co-Authored-By: Dishit Karia --- tsconfig.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index efd02be9..9cfbe3ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,10 @@ "extends": "@react-native/typescript-config", "compilerOptions": { "types": ["jest", "node"], + "baseUrl": ".", + "paths": { + "@offgrid/core/*": ["src/*"] + }, }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["**/node_modules", "**/Pods", "pro/**"] From f2ca56935811d88c34c2bef072271c9076e8e26b Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 19 Jun 2026 15:05:42 +0530 Subject: [PATCH 08/10] fix: make CI green without the private pro submodule Two CI-only failures (both pass locally where the pro submodule is checked out and revenueCatKeys.ts has the full local copy): - revenueCatKeys.ts committed an older placeholder missing RC_WEB_PURCHASE_URL, which proLicenseService imports. Add the (public, safe-to-commit) web purchase URL placeholder so the committed config exports every symbol the app uses. - EmailCalendarExtension.test.ts imported the pro module by a static path that tsc/jest cannot resolve when pro/ is absent (open-core CI does not check out the private submodule). Load it via a computed-path require and skip the suite when missing; it still runs locally and in the pro repo CI. Co-Authored-By: Dishit Karia --- .../tools/EmailCalendarExtension.test.ts | 22 ++++++++++++++++--- src/config/revenueCatKeys.ts | 8 +++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/__tests__/unit/services/tools/EmailCalendarExtension.test.ts b/__tests__/unit/services/tools/EmailCalendarExtension.test.ts index 9baa2276..ea9c4d65 100644 --- a/__tests__/unit/services/tools/EmailCalendarExtension.test.ts +++ b/__tests__/unit/services/tools/EmailCalendarExtension.test.ts @@ -9,6 +9,7 @@ import { Linking } from 'react-native'; import type { ToolCall } from '../../../../src/services/tools/types'; +import type { ToolExtension } from '../../../../src/services/tools/extensions'; let mockEnabledTools: string[] = []; jest.mock('@offgrid/core/stores', () => ({ @@ -27,8 +28,23 @@ jest.mock('react-native-calendar-events', () => ({ }, })); -// Imported after the mocks above are registered (jest hoists jest.mock). -import { EmailCalendarExtension } from '../../../../pro/tools/EmailCalendarExtension'; +// The implementation lives in the private pro submodule, which is not checked +// out in the open-core CI. Load it dynamically via a computed path (so tsc does +// not try to resolve the absent module) and skip the suite when it is missing. +// jest hoists the jest.mock calls above this, so the mocks are already registered. +function loadProExtension(): ToolExtension | null { + const proPath = ['..', '..', '..', '..', 'pro', 'tools', 'EmailCalendarExtension'].join('/'); + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require(proPath).EmailCalendarExtension as ToolExtension; + } catch { + return null; + } +} + +const proExtension = loadProExtension(); +const EmailCalendarExtension = proExtension ?? ({} as ToolExtension); +const describeIfPro = proExtension ? describe : describe.skip; const mockOpenURL = jest.spyOn(Linking, 'openURL'); @@ -36,7 +52,7 @@ function call(name: string, args: Record = {}): ToolCall { return { id: `c-${name}`, name, arguments: args }; } -describe('EmailCalendarExtension', () => { +describeIfPro('EmailCalendarExtension', () => { beforeEach(() => { mockEnabledTools = []; mockOpenURL.mockReset().mockResolvedValue(undefined as never); diff --git a/src/config/revenueCatKeys.ts b/src/config/revenueCatKeys.ts index a78d6b0e..a52e03f1 100644 --- a/src/config/revenueCatKeys.ts +++ b/src/config/revenueCatKeys.ts @@ -23,3 +23,11 @@ export const RC_API_KEY_ANDROID = 'goog_REPLACE_WITH_ANDROID_KEY'; */ export const RC_API_KEY_TEST_STORE = 'test_UDUmOVwoEWFUtYONRUfQOOjVisB'; export const USE_RC_TEST_STORE = false; + +/** + * RevenueCat Web Purchase Link (RC Billing / Stripe checkout). Not a secret — safe to + * commit. Get it from the RC dashboard → Web → Web Purchase Links (for the offering). + * The app appends `?app_user_id=` so the purchase attaches to the buyer's email + * identity, which is the same identity the app logs in as to unlock Pro. + */ +export const RC_WEB_PURCHASE_URL = 'https://pay.rev.cat/avvnmcnfsgbmjaee/'; From 48bfd36721a459932aa3c4836fb32933af290dd1 Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 19 Jun 2026 17:31:21 +0530 Subject: [PATCH 09/10] feat: pro unlock modal polish (dismiss, border, input-gated CTA) - Success card gets a 'Got it' button (and onRequestClose) so the user can read the activation message and dismiss it, instead of being trapped with no way out but force-killing the app. - Card now has a visible border (colors.border) so it reads in dark mode, where the shadow alone disappeared against the background. - CTA (Continue to payment / Verify and unlock) is enabled only once non-whitespace text is entered; no format validation. Whitespace is stripped before use, matching the service-layer trim/lowercase. - Drop the stale RNRestart comment in ProDetailScreen (the lib was removed); Pro now loads on next launch via checkProStatus. - Tests updated for the disabled-until-typed CTA, whitespace handling, and the success-card dismiss. Co-Authored-By: Dishit Karia --- .../rntl/screens/ProDetailScreen.test.tsx | 39 +++++++++++++++++-- .../ProDetailScreen/ProUnlockModal.tsx | 39 +++++++++++++++---- src/screens/ProDetailScreen/index.tsx | 5 ++- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/__tests__/rntl/screens/ProDetailScreen.test.tsx b/__tests__/rntl/screens/ProDetailScreen.test.tsx index 92f3374e..1f37e17a 100644 --- a/__tests__/rntl/screens/ProDetailScreen.test.tsx +++ b/__tests__/rntl/screens/ProDetailScreen.test.tsx @@ -61,6 +61,18 @@ describe('ProDetailScreen', () => { await waitFor(() => expect(getByText('Pro activated')).toBeTruthy()); }); + it('lets the user dismiss the success card with Got it', async () => { + mockActivateProByEmail.mockResolvedValueOnce(true); + const { getAllByText, getByText, queryByText, getByPlaceholderText } = render(); + fireEvent.press(getAllByText('Get Pro')[0]); + fireEvent.changeText(getByPlaceholderText('you@example.com'), 'buyer@example.com'); + fireEvent.press(getByText('Already paid? Verify email instead')); + fireEvent.press(getByText('Verify and unlock')); + await waitFor(() => expect(getByText('Pro activated')).toBeTruthy()); + fireEvent.press(getByText('Got it')); + await waitFor(() => expect(queryByText('Pro activated')).toBeNull()); + }); + it('shows inline error when no purchase is found for that email', async () => { mockActivateProByEmail.mockResolvedValueOnce(false); const { getAllByText, getByText, getByPlaceholderText } = render(); @@ -71,11 +83,32 @@ describe('ProDetailScreen', () => { await waitFor(() => expect(getByText(/No Pro purchase found/)).toBeTruthy()); }); - it('shows inline error when email is empty on checkout', async () => { - const { getAllByText, getByText } = render(); + it('keeps the checkout button disabled until text is entered', async () => { + const { getAllByText, getByText, getByPlaceholderText } = render(); + fireEvent.press(getAllByText('Get Pro')[0]); + // Empty input: the disabled button ignores the press, no checkout opens. + fireEvent.press(getByText('Continue to payment')); + expect(linkingSpy).not.toHaveBeenCalled(); + // Once text is entered the button is enabled and opens checkout. + fireEvent.changeText(getByPlaceholderText('you@example.com'), 'buyer@example.com'); + fireEvent.press(getByText('Continue to payment')); + await waitFor(() => expect(linkingSpy).toHaveBeenCalled()); + }); + + it('treats whitespace-only input as empty so the button stays disabled', () => { + const { getAllByText, getByText, getByPlaceholderText } = render(); fireEvent.press(getAllByText('Get Pro')[0]); + fireEvent.changeText(getByPlaceholderText('you@example.com'), ' '); fireEvent.press(getByText('Continue to payment')); - await waitFor(() => expect(getByText('Enter your email first.')).toBeTruthy()); + expect(linkingSpy).not.toHaveBeenCalled(); + }); + + it('strips surrounding whitespace before opening checkout', async () => { + const { getAllByText, getByText, getByPlaceholderText } = render(); + fireEvent.press(getAllByText('Get Pro')[0]); + fireEvent.changeText(getByPlaceholderText('you@example.com'), ' buyer@example.com '); + fireEvent.press(getByText('Continue to payment')); + await waitFor(() => expect(mockGetWebPurchaseUrl).toHaveBeenCalledWith('buyer@example.com')); }); it('renders the Pro Active state when the user already owns Pro', () => { diff --git a/src/screens/ProDetailScreen/ProUnlockModal.tsx b/src/screens/ProDetailScreen/ProUnlockModal.tsx index 060cba78..c48ab6f7 100644 --- a/src/screens/ProDetailScreen/ProUnlockModal.tsx +++ b/src/screens/ProDetailScreen/ProUnlockModal.tsx @@ -48,14 +48,21 @@ export const ProUnlockModal: React.FC = ({ visible, onClose, onUnlocked } onClose(); }; + // Dismiss the success card once the user has read it. The keychain write is + // already done at this point; Pro features load on the next app launch. + const finishSuccess = () => { + reset(); + onClose(); + }; + const clearError = () => { if (error) setError(null); }; const handlePrimary = async () => { + // Strip leading/trailing whitespace so stray spaces never reach the URL or + // the RevenueCat identity. The button is disabled when empty, so this is a + // defensive guard rather than a validation message. const trimmed = email.trim(); - if (!trimmed) { - setError('Enter your email first.'); - return; - } + if (!trimmed) return; if (mode === 'pay') { try { @@ -86,7 +93,7 @@ export const ProUnlockModal: React.FC = ({ visible, onClose, onUnlocked } if (success) { return ( - {}}> + @@ -94,6 +101,9 @@ export const ProUnlockModal: React.FC = ({ visible, onClose, onUnlocked } Pro activated Close and reopen the app to load your Pro features. + + Got it + @@ -101,6 +111,9 @@ export const ProUnlockModal: React.FC = ({ visible, onClose, onUnlocked } } const isPay = mode === 'pay'; + // Enable the CTA only once non-whitespace text is entered. No format check — + // any text is allowed; only whitespace-only input keeps the button disabled. + const hasInput = email.trim().length > 0; return ( @@ -140,9 +153,9 @@ export const ProUnlockModal: React.FC = ({ visible, onClose, onUnlocked } {/* Primary CTA */} {isPay ? ( @@ -187,6 +200,8 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ card: { backgroundColor: colors.surface, borderRadius: 20, + borderWidth: 1, + borderColor: colors.border, paddingHorizontal: SPACING.xl, paddingTop: SPACING.md, paddingBottom: SPACING.xl, @@ -300,4 +315,14 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ color: colors.textSecondary, textAlign: 'center' as const, }, + successBtn: { + backgroundColor: colors.primary, + borderRadius: 14, + paddingVertical: SPACING.lg, + paddingHorizontal: SPACING.lg, + alignItems: 'center' as const, + justifyContent: 'center' as const, + alignSelf: 'stretch' as const, + marginTop: SPACING.xl, + }, }); diff --git a/src/screens/ProDetailScreen/index.tsx b/src/screens/ProDetailScreen/index.tsx index 983e2a80..1873a265 100644 --- a/src/screens/ProDetailScreen/index.tsx +++ b/src/screens/ProDetailScreen/index.tsx @@ -25,8 +25,9 @@ export const ProDetailScreen: React.FC = () => { const openEmailModal = () => setEmailModalVisible(true); - // The modal handles the restart via RNRestart after showing the success state. - // Nothing to do here — onUnlocked is a signal that purchase completed. + // Purchase verified: the modal shows its own success card and the keychain is + // already written. Pro features load on the next app launch (checkProStatus + // reads the entitlement at boot), so there is nothing to do here. const handleUnlocked = () => {}; return ( From d17f218c9d98422dc8c7bc03c1403041d0650991 Mon Sep 17 00:00:00 2001 From: Dishit Date: Fri, 19 Jun 2026 17:31:36 +0530 Subject: [PATCH 10/10] chore: bump pro submodule for MCP screen fixes Points the pro gitlink at af5aa10 (offgrid-pro feat/email-calendar-tools) which includes the MCP servers screen header inset fix, removal of the demo-servers link, and the now-required server name field. Co-Authored-By: Dishit Karia --- pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pro b/pro index ed9b93b8..af5aa108 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit ed9b93b8aa97814a6b52942076031090f4b40b4e +Subproject commit af5aa108fde24d2badb197b238db034e77598400