diff --git a/App.tsx b/App.tsx index 5da6ea0a7..5adb20e9d 100644 --- a/App.tsx +++ b/App.tsx @@ -11,10 +11,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { NavigationContainer } from '@react-navigation/native'; import { AppNavigator } from './src/navigation'; -import { - appNavigationRef, - useProExpiryRedirect, -} from './src/navigation/useProExpiryRedirect'; +import { appNavigationRef } from './src/navigation/navigationRef'; import { useTheme } from './src/theme'; import { hardwareService, modelManager, authService, knowledgeBase, remoteServerManager } from './src/services'; import logger from './src/utils/logger'; @@ -133,7 +130,6 @@ function App() { // the appRoot slot (TTS engine bridge) registers and this re-renders to mount // it live — no restart needed. const AppRoot = useSlot(SLOTS.appRoot); - const applyPendingProRedirect = useProExpiryRedirect(); const [isInitializing, setIsInitializing] = useState(true); const startupGeneration = useRef(0); const showSyncStatusBar = useAppStore(s => s.showSyncStatusBar); @@ -435,7 +431,6 @@ function App() { {AppRoot ? : null} off-grid-ai%2FOGAM | Trendshift @@ -37,26 +37,29 @@
-## Off Grid AI Pro +## Everything is free -**A voice, personas, and actions. $119 for life, or $7.99/month.** +**A voice, personas, and actions. No licence, no subscription, nothing to unlock.**

-Pro is an optional, additive tier. It gives the assistant a voice that talks back, personas you shape, and the tools to draft real actions you approve. One license covers your phone and your Mac. All on-device. +Off Grid AI has no paid tier. Every capability the app has is available to everyone: the assistant has a +voice that talks back, personas you shape, and the tools to draft real actions you approve. Your phone and +your Mac are both covered. All on-device. -### What Pro adds +### What you get -- **Voice mode** - the free app transcribes your speech; Pro adds on-device Kokoro text-to-speech, so it talks back and you run the whole thing hands-free. The voice runs in your phone's RAM. +- **Voice mode** - on-device Kokoro text-to-speech, so the assistant talks back and you run the whole thing hands-free. The voice runs in your phone's RAM. - **Custom personas** - give each assistant its own system prompt, voice, and persistent memory, so it stays in character across conversations. - **Draft, then approve** - connect Calendar, email, and MCP servers like Linear, Notion, and GitHub. It drafts the reply or files the ticket and waits. Nothing sends without your tap. - **Personal Mesh sync** - your phone and your Mac share chats, files, and settings over your own network. There is no relay. See the [Personal Mesh guide](docs/PERSONAL_MESH.md). -**[→ Get Pro access](https://getoffgridai.co/pay/)** - $119 once and it is yours forever (the price climbs as more people join, never down), or $7.99/month. +There is no purchase path in the app, no licence key to paste, and no expiry: the old paid tier was +removed, and the licence plumbing that remains is only what pairing your own devices needs. -Pair it with **[Off Grid AI Desktop](https://github.com/off-grid-ai/desktop/releases/latest)** on your Mac. One Pro license covers both. +Pair it with **[Off Grid AI Desktop](https://github.com/off-grid-ai/desktop/releases/latest)** on your Mac. --- diff --git a/__tests__/harness/proHarness.ts b/__tests__/harness/proHarness.ts index b2ed191fc..58229fce6 100644 --- a/__tests__/harness/proHarness.ts +++ b/__tests__/harness/proHarness.ts @@ -12,11 +12,11 @@ * registering individual slots per test. */ export async function installPro(): Promise { - // isPro=true → the real bootstrap requires @offgrid/pro and calls pro.activate({...registries}), - // registering every pro slot (chatInput.modeToggle, chatInput.audioMode, message.speakButton, …). - + // The real bootstrap requires @offgrid/pro and calls pro.activate({...registries}), registering every + // pro slot (chatInput.modeToggle, chatInput.audioMode, message.speakButton, …). Nothing is gated any + // more: a build with the bundle activates it with no licence, which is what this harness stands in for. const { loadProFeatures } = require('../../src/bootstrap/loadProFeatures'); - await loadProFeatures(true); + await loadProFeatures(); } /** Start the real app over device storage. The returned controls use only visible gestures. */ diff --git a/__tests__/integration/licensing/proRuntimeExpiry.test.ts b/__tests__/integration/licensing/proRuntimeExpiry.test.ts deleted file mode 100644 index b3456fd0b..000000000 --- a/__tests__/integration/licensing/proRuntimeExpiry.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { callHook, _clearHooksForTesting } from '../../../src/bootstrap/hookRegistry'; -import { getSlot, SLOTS, _clearSlotsForTesting } from '../../../src/bootstrap/slotRegistry'; -import { - getRegisteredScreens, - registerScreen, - _clearScreensForTesting, -} from '../../../src/navigation/screenRegistry'; -import { - getToolExtensions, - registerToolExtension, - _clearExtensionsForTesting, -} from '../../../src/services/tools/extensions'; -import { registerSettingsSection } from '../../../src/components/settings/sectionRegistry'; -import { registerHook } from '../../../src/bootstrap/hookRegistry'; -import { registerSlot } from '../../../src/bootstrap/slotRegistry'; -import { activate, deactivate } from '../../../pro'; -import { syncService } from '../../../pro/sync/syncService'; - -jest.mock('react-native-tcp-socket', () => { - const { - createNativeTcpBoundary, - } = require('../../utils/nativeSyncBoundaries'); - return { __esModule: true, default: createNativeTcpBoundary() }; -}); - -jest.mock('react-native-zeroconf', () => { - const { - createNativeDiscoveryBoundary, - } = require('../../utils/nativeSyncBoundaries'); - return { __esModule: true, default: createNativeDiscoveryBoundary() }; -}); - -describe('the Pro runtime when access expires', () => { - beforeEach(() => { - _clearHooksForTesting(); - _clearSlotsForTesting(); - _clearScreensForTesting(); - _clearExtensionsForTesting(); - }); - - afterEach(async () => { - await deactivate(); - // The restricted Sync bootstrap intentionally survives Pro deactivation. This test starts that - // process, so it must also await and stop it before Jest removes the native boundary. - await syncService.stop(); - _clearHooksForTesting(); - _clearSlotsForTesting(); - _clearScreensForTesting(); - _clearExtensionsForTesting(); - }); - - it('removes every paid surface and stops Sync without an app restart', async () => { - activate({ - registerToolExtension, - registerScreen, - registerSettingsSection, - registerSlot, - registerHook, - }); - - expect(getRegisteredScreens().map(screen => screen.name)).toEqual( - expect.arrayContaining(['Sync', 'Clipboard', 'McpServers']), - ); - expect(getToolExtensions().map(extension => extension.id)).toEqual( - expect.arrayContaining(['mcp', 'email-calendar']), - ); - expect(getSlot(SLOTS.appRoot)).toBeDefined(); - expect(callHook('audio.canSpeak')).toBeDefined(); - - await deactivate(); - - expect(getRegisteredScreens().map(screen => screen.name)).not.toEqual( - expect.arrayContaining(['Sync', 'Clipboard', 'McpServers']), - ); - expect(getToolExtensions().map(extension => extension.id)).toEqual([]); - expect(getSlot(SLOTS.appRoot)).toBeUndefined(); - expect(callHook('audio.canSpeak')).toBeUndefined(); - expect(syncService.isRunning()).toBe(false); - }); -}); diff --git a/__tests__/integration/onboarding/proBootFlow.test.ts b/__tests__/integration/onboarding/proBootFlow.test.ts deleted file mode 100644 index dd8e412c4..000000000 --- a/__tests__/integration/onboarding/proBootFlow.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { useAppStore } from '../../../src/stores/appStore'; -import { createKeygenFake } from '../../harness/keygenFake'; - -/** - * Opening the app as a Pro user, and as a free one. - * - * Two facts are established in the first moments of a launch: whether this phone is entitled, and - - * only if it is - the licensed half of the app being switched on. Getting either wrong is immediately - * visible: a paying user greeted by the upgrade prompt, or a free build with pro screens in the nav. - * - * The entitlement is read from the keychain, and the launch awaits ONE check against the provider before - * anything is drawn - so a stale cache is corrected without the user seeing a flash of the wrong screen, - * and a provider that cannot be reached falls back to the cached answer rather than to nothing. - * - * This used to stand in for the app's own store and licence service with hand-written partial objects, - * and both drifted behind the real modules - the store gained a setter, the service gained a function, - * and the tests failed for reasons that had nothing to do with booting. Now the store, the licence - * service, the cache and the registries are real; the keychain, the device fingerprint and Keygen's - * HTTP endpoint stand in, because those three are the device and the network. - */ - -const LICENCE_KEY = 'OFFGRID-TEST-LICENCE'; -const FINGERPRINT = 'fp-this-phone'; - -// The pro package itself, which is what activation switches on. Its own contents are its own tests' -// business; what matters here is that it is handed the registries, and that it is handed the function -// that registers the entitlement provider - without which the app can never read a licence at all. -jest.mock( - '@offgrid/pro', - () => { - const { proLicenseProvider } = require('../../../pro/licensing/proLicenseProvider'); - return { - activate: jest.fn(), - configureProEntitlementProvider: (register: (provider: unknown) => void) => { - register(proLicenseProvider); - }, - }; - }, - { virtual: true }, -); - -describe('opening the app as a Pro user, and as a free one', () => { - const keygen = createKeygenFake(); - let vault: Map; - let licenceId = ''; - let activate: jest.Mock; - let originalDev: unknown; - - /** The app store the launch actually wrote to, which is the reloaded copy rather than this one. */ - const storeState = (): Record => - require('../../../src/stores/appStore').useAppStore.getState(); - - /** What the keychain holds, as the next launch would read it. */ - const cached = (): Record | null => { - const raw = vault.get('off-grid-pro-license'); - return raw ? (JSON.parse(raw) as Record) : null; - }; - - /** - * Everything below the app: the keychain, the hardware id and Keygen's endpoint. - * - * Bound from the CURRENT module graph, because each launch reloads it - the entitlement lifecycle - * memoises its one launch-time revalidation, so a second launch in the same graph silently skips the - * check this suite is about. Reloading means the keychain the code under test sees is a different copy - * of the module, and it has to be re-wired to the same vault or every cached licence reads as absent. - */ - function installBoundaries(): void { - const fingerprint = require('../../../pro/licensing/deviceFingerprint'); - jest.spyOn(fingerprint, 'getDeviceFingerprintStrict').mockResolvedValue(FINGERPRINT); - jest.spyOn(fingerprint, 'getDeviceFingerprint').mockResolvedValue(FINGERPRINT); - - const secure = require('react-native-keychain'); - secure.getGenericPassword.mockImplementation( - async ({ service }: { service: string }) => { - const value = vault.get(service); - return value ? { username: 'stored', password: value } : false; - }, - ); - secure.setGenericPassword.mockImplementation( - async (_user: string, password: string, { service }: { service: string }) => { - vault.set(service, password); - return true; - }, - ); - secure.resetGenericPassword?.mockImplementation?.( - async ({ service }: { service: string }) => vault.delete(service), - ); - } - - beforeEach(() => { - vault = new Map(); - keygen.reset(); - keygen.install(); - licenceId = keygen.addLicence({ key: LICENCE_KEY, seats: 3 }); - - // Pro is force-unlocked in development, which would activate regardless of the entitlement. These - // tests are about the SHIPPED gating, so the flag is the one a release build has. - originalDev = (global as { __DEV__?: unknown }).__DEV__; - (global as { __DEV__?: unknown }).__DEV__ = false; - useAppStore.getState().setProActive(false); - useAppStore.getState().setHasRegisteredPro(false); - }); - - afterEach(() => { - keygen.restore(); - jest.restoreAllMocks(); - (global as { __DEV__?: unknown }).__DEV__ = originalDev; - }); - - /** - * A launch, exactly as App.tsx does it: ONE call, with no entitlement passed in. - * - * This used to call checkProStatus() first and pass the answer along. The app does not - and cannot: - * the entitlement provider is registered BY this call, so anything read before it comes back false - * from the stand-in provider and would be passed in as a definite "not Pro", overriding the real - * cached licence. - */ - async function launch(): Promise { - jest.resetModules(); - installBoundaries(); - activate = require('@offgrid/pro').activate as jest.Mock; - activate.mockClear(); - const { loadProFeatures } = require('../../../src/bootstrap/loadProFeatures'); - return loadProFeatures(); - } - - it('leaves the licensed half switched off for a phone with no licence', async () => { - const active = await launch(); - - expect(active).toBe(false); - // Not merely hidden: the pro package is never activated, so its screens are not registered and its - // background work never starts on a device that is not entitled. - expect(activate).not.toHaveBeenCalled(); - expect(storeState().isProActive).toBe(false); - }); - - it('does not boot Pro from an expired saved credential', async () => { - vault.set( - 'off-grid-pro-license', - JSON.stringify({ - isPro: true, - key: LICENCE_KEY, - licenseId: licenceId, - expiry: new Date(Date.now() - 1).toISOString(), - verifiedAt: Date.now() - 10_000, - }), - ); - - const active = await launch(); - - expect(active).toBe(false); - expect(activate).not.toHaveBeenCalled(); - expect(storeState()).toMatchObject({ - isProActive: false, - hasRegisteredPro: false, - hasSavedProCredential: true, - hasExpiredProCredential: true, - }); - }); - - it('switches the licensed half on for a phone that already holds one', async () => { - // A Pro phone holds a seat on the licence as well as a key in its keychain. Without the seat the - // launch-time check correctly withdraws Pro - a key that no longer has a device registered against - // it is not an entitlement, however valid the key itself is. - keygen.activate({ - key: LICENCE_KEY, - fingerprint: FINGERPRINT, - name: 'iPhone', - platform: 'ios', - }); - vault.set( - 'off-grid-pro-license', - JSON.stringify({ - isPro: true, - key: LICENCE_KEY, - licenseId: licenceId, - expiry: null, - verifiedAt: Date.now(), - }), - ); - - const active = await launch(); - - expect(active).toBe(true); - // The three ways the pro package reaches the user: a tool the chat can call, a screen in the nav, - // and a section in Settings. Activation hands it all three, so a missing one is a whole surface the - // user cannot reach with nothing failing anywhere. - expect(activate).toHaveBeenCalledWith( - expect.objectContaining({ - registerToolExtension: expect.any(Function), - registerScreen: expect.any(Function), - registerSettingsSection: expect.any(Function), - }), - ); - }); - - it('answers from the keychain first, without waiting for the network', async () => { - keygen.setOffline(true); - vault.set( - 'off-grid-pro-license', - JSON.stringify({ - isPro: true, - key: LICENCE_KEY, - licenseId: licenceId, - expiry: null, - verifiedAt: Date.now(), - }), - ); - - const active = await launch(); - - // A phone on a plane is still a phone the user paid for. Waiting on the provider would leave the - // app deciding what to draw against a request that will time out. - expect(active).toBe(true); - expect(activate).toHaveBeenCalled(); - keygen.setOffline(false); - }); - - it('corrects a stale cached answer in the background', async () => { - // Cached as not entitled while a key is present - the state after a launch that could not reach the - // provider, or an activation that was interrupted. - keygen.activate({ - key: LICENCE_KEY, - fingerprint: FINGERPRINT, - name: 'iPhone', - platform: 'ios', - }); - vault.set( - 'off-grid-pro-license', - JSON.stringify({ - isPro: false, - key: LICENCE_KEY, - licenseId: licenceId, - expiry: null, - verifiedAt: 0, - }), - ); - - const active = await launch(); - - // Corrected BEFORE the first screen is drawn, not afterwards: the launch awaits one revalidation, so - // a user whose licence is perfectly fine never sees a flash of the upgrade prompt. The old version of - // this test expected the opposite and waited for a background pass that had already happened. - expect(active).toBe(true); - expect(cached()).toMatchObject({ isPro: true, key: LICENCE_KEY }); - expect(storeState().hasRegisteredPro).toBe(true); - }); - - it('does not go on believing a licence the provider has stopped honouring', async () => { - vault.set( - 'off-grid-pro-license', - JSON.stringify({ - isPro: true, - key: 'OFFGRID-A-LICENCE-THAT-WAS-REVOKED', - licenseId: licenceId, - expiry: null, - verifiedAt: 0, - }), - ); - - const active = await launch(); - - // A refund, an expiry, a key entered on too many devices. The cached answer got the user in once - - // it must not get them in for ever, and the correction lands on this launch rather than the next. - expect(active).toBe(false); - expect(cached()).toMatchObject({ isPro: false }); - expect(storeState().isProActive).toBe(false); - }); - - it('keeps believing a cached licence when the provider cannot be reached', async () => { - keygen.setOffline(true); - vault.set( - 'off-grid-pro-license', - JSON.stringify({ - isPro: true, - key: LICENCE_KEY, - licenseId: licenceId, - expiry: null, - verifiedAt: 0, - }), - ); - - const active = await launch(); - keygen.setOffline(false); - - // The difference that matters: a failed REQUEST is not a refused licence. Treating the two the same - // would take Pro away from anybody who opened the app on a train. - expect(active).toBe(true); - expect(cached()).toMatchObject({ isPro: true }); - }); -}); diff --git a/__tests__/integration/pro/everyFeatureIsFree.test.ts b/__tests__/integration/pro/everyFeatureIsFree.test.ts new file mode 100644 index 000000000..f580d50b2 --- /dev/null +++ b/__tests__/integration/pro/everyFeatureIsFree.test.ts @@ -0,0 +1,160 @@ +/** + * Every feature is free. + * + * SPEC (OGAM user's view): nobody pays, and nobody is asked to. A phone that has never held a licence, one + * whose licence lapsed, and one the licensed-device roster has dropped all get the same app: the optional + * pro bundle is loaded and switched on for each of them, and no screen offers a price, a key, or a way in. + * + * This replaces the suite that asserted the opposite - "leaves the licensed half switched off for a phone + * with no licence" - because that was the paid contract, and the paid contract is gone. What has to hold + * now is that access is not a function of the two facts it used to be a function of: whether a credential + * exists, and what the roster last said about this device. + * + * The bundle itself is mocked (a licence-free build still has to load whatever IS in the build), and + * everything under it - the store, the entitlement service, the registries - is real. + */ +import { + createProAccessSlice, + selectHasProAccess, + type ProAccessSlice, +} from '../../../src/stores/proAccessSlice'; + +const mockActivate = jest.fn(); +const mockConfigureProvider = jest.fn(); +const mockActivateSyncBootstrap = jest.fn(); + +jest.mock( + '@offgrid/pro', + () => ({ + activate: (...args: unknown[]) => mockActivate(...args), + activateSyncBootstrap: (...args: unknown[]) => + mockActivateSyncBootstrap(...args), + configureProEntitlementProvider: (...args: unknown[]) => + mockConfigureProvider(...args), + }), + { virtual: true }, +); + +import { loadProFeatures } from '../../../src/bootstrap/loadProFeatures'; +import { useAppStore } from '../../../src/stores/appStore'; + +/** A store slice in the given state, built through the real factory so the shape cannot drift. */ +function sliceWith(overrides: Partial = {}): ProAccessSlice { + return { ...createProAccessSlice(() => undefined), ...overrides }; +} + +describe('the pro bundle switched on without a purchase', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + // The store is real and persists: put the licence projection back so later cases start from nothing. + useAppStore.setState({ + hasRegisteredPro: false, + hasSavedProCredential: false, + isProActive: false, + hasExpiredProCredential: false, + proDeviceAdmission: 'unknown', + }); + }); + + it('activates the bundle on an install that has never held a licence', async () => { + // Nothing bought, nothing cached: the projection stays empty... + expect(useAppStore.getState().hasRegisteredPro).toBe(false); + + const loaded = await loadProFeatures(); + + // ...and the features load anyway, because activation is handed the registries that mount them. + expect(loaded).toBe(true); + expect(mockConfigureProvider).toHaveBeenCalledTimes(1); + expect(mockActivate).toHaveBeenCalledTimes(1); + expect(mockActivate).toHaveBeenCalledWith( + expect.objectContaining({ + registerScreen: expect.any(Function), + registerSlot: expect.any(Function), + registerHook: expect.any(Function), + registerSettingsSection: expect.any(Function), + registerToolExtension: expect.any(Function), + }), + ); + expect(selectHasProAccess(useAppStore.getState())).toBe(true); + }); + + it('registers the Sync bootstrap even before anything is activated', async () => { + await loadProFeatures(); + + expect(mockActivateSyncBootstrap).toHaveBeenCalledTimes(1); + expect(mockActivateSyncBootstrap).toHaveBeenCalledWith( + expect.objectContaining({ + registerScreen: expect.any(Function), + registerSlot: expect.any(Function), + registerHook: expect.any(Function), + }), + ); + }); + + it('activates the bundle for a dropped device holding an expired credential', async () => { + // The two facts the old rule refused on, at once: a credential that has lapsed, and a roster that + // says this phone is not on the licence. Free means free, so the bundle loads for it too. + useAppStore.setState({ + hasRegisteredPro: true, + hasSavedProCredential: true, + isProActive: false, + hasExpiredProCredential: true, + proDeviceAdmission: 'inactive', + }); + + const loaded = await loadProFeatures(); + + expect(loaded).toBe(true); + expect(mockActivate).toHaveBeenCalledTimes(1); + }); +}); + +describe('access is not a function of a credential or a device roster', () => { + it('grants a device that holds nothing, with a roster that has said nothing', () => { + expect(selectHasProAccess(sliceWith())).toBe(true); + }); + + it('grants a device whose saved credential reached its deadline', () => { + expect( + selectHasProAccess( + sliceWith({ + hasRegisteredPro: true, + hasSavedProCredential: true, + isProActive: false, + hasExpiredProCredential: true, + }), + ), + ).toBe(true); + }); + + it('grants a device the licensed-device roster removed', () => { + // This is the case the old rule existed to catch, and the case it refused: a phone taken off the + // roster lost every paid surface. Free means free, so it keeps them. + expect( + selectHasProAccess( + sliceWith({ + hasRegisteredPro: true, + hasSavedProCredential: true, + isProActive: true, + proDeviceAdmission: 'inactive', + }), + ), + ).toBe(true); + }); + + it('grants a device on a roster that has not answered yet', () => { + expect( + selectHasProAccess( + sliceWith({ + hasRegisteredPro: true, + hasSavedProCredential: true, + isProActive: true, + proDeviceAdmission: 'unknown', + }), + ), + ).toBe(true); + }); +}); diff --git a/__tests__/integration/pro/proAhaSheetPricing.rendered.test.tsx b/__tests__/integration/pro/proAhaSheetPricing.rendered.test.tsx deleted file mode 100644 index 1e7908ab6..000000000 --- a/__tests__/integration/pro/proAhaSheetPricing.rendered.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** - * The Pro offer that appears in chat states the current price. - * - * SPEC (OGAM user's view): after a free user has had enough real conversations to know the - * product (their 10th completed reply), chat offers Off Grid AI Pro once. That offer states the - * current founder price - $119 once for life, or $7.99 a month - and never an older tier ($49, - * $69) or a yearly plan ("/yr", "year"). Before the 10th reply, no offer and no price is on screen. - * - * Journey: the REAL ChatScreen and the REAL generation pipeline over the llama.rn native fake - * (chatHarness). The user types and sends ten messages across two chats (five each, then a new - * chat, as a user spreads work over conversations); each reply streams back from the native fake. - * Nothing seeds the generation count: the ten real sends produce it. - */ -import { setupChatScreen } from '../../harness/chatHarness'; - -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }), - useRoute: () => require('../../harness/chatHarness').routeHolder, - useFocusEffect: () => {}, - useIsFocused: () => true, -})); - -const OFFER_HEADLINE = 'It already knows what you did today.'; -// The chat offer appears a moment after the reply finishes; give it more than that moment. -const OFFER_SETTLE_MS = 2_000; -const STALE_PRICE_COPY = [/\$49/, /\$69/, /\/yr/i, /year/i]; - -describe('Pro offer in chat states the current price (rendered)', () => { - it('after the 10th reply the offer shows $119 once for life or $7.99 a month, and no old or yearly price', async () => { - const h = await setupChatScreen({ engine: 'llama' }); - const view = h.render(); - - for (let turn = 1; turn <= 9; turn += 1) { - if (turn === 6) { - // The user reopens the first chat from the chat list, then starts a new chat. - await h.openConversation(h.conversationId!); - await h.openConversation(); - expect(view.queryByText('Answer number 5.')).toBeNull(); - } - await h.send(`question number ${turn}`, { text: `Answer number ${turn}.` }); - await h.rtl.waitFor(() => expect(view.getByText(`Answer number ${turn}.`)).toBeTruthy()); - } - - // BEFORE: nine replies in, and past the moment an offer would appear, there is no offer and - // no price on screen. - await h.settle(OFFER_SETTLE_MS); - expect(view.queryByText(OFFER_HEADLINE)).toBeNull(); - expect(view.queryByText(/\$119/)).toBeNull(); - expect(view.queryByText(/\$7\.99/)).toBeNull(); - - await h.send('question number 10', { text: 'Answer number 10.' }); - await h.rtl.waitFor(() => expect(view.getByText('Answer number 10.')).toBeTruthy()); - - await h.rtl.waitFor(() => expect(view.getByText(OFFER_HEADLINE)).toBeTruthy(), { - timeout: OFFER_SETTLE_MS + 2_000, - }); - expect(view.getByText(/\$119 once for life/)).toBeTruthy(); - expect(view.getByText(/\$7\.99 a month/)).toBeTruthy(); - for (const stale of STALE_PRICE_COPY) { - expect(view.queryAllByText(stale)).toHaveLength(0); - } - }, 120_000); -}); diff --git a/__tests__/integration/pro/proExpiryRedirect.integration.test.tsx b/__tests__/integration/pro/proExpiryRedirect.integration.test.tsx deleted file mode 100644 index 45836480d..000000000 --- a/__tests__/integration/pro/proExpiryRedirect.integration.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import { act, render } from '@testing-library/react-native'; -import type { NavigationContainerRef } from '@react-navigation/native'; -import { useProExpiryRedirect } from '../../../src/navigation/useProExpiryRedirect'; -import type { RootStackParamList } from '../../../src/navigation/types'; -import { useAppStore } from '../../../src/stores/appStore'; - -type TestNavigation = Pick< - NavigationContainerRef, - 'isReady' | 'resetRoot' ->; - -function Probe({ navigation }: { navigation: TestNavigation }): null { - useProExpiryRedirect(navigation); - return null; -} - -describe('the live Pro expiry redirect', () => { - const resetRoot = jest.fn(); - let ready = true; - const navigation: TestNavigation = { - isReady: () => ready, - resetRoot, - }; - - beforeEach(() => { - ready = true; - resetRoot.mockClear(); - useAppStore.setState({ - hasRegisteredPro: false, - hasSavedProCredential: false, - isProActive: false, - proDeviceAdmission: 'unknown', - }); - }); - - it('does not redirect a normal free launch', () => { - render(); - expect(resetRoot).not.toHaveBeenCalled(); - }); - - it('replaces the current route with the purchase screen when access is lost', () => { - useAppStore.setState({ - hasRegisteredPro: true, - hasSavedProCredential: true, - isProActive: true, - proDeviceAdmission: 'active', - }); - render(); - - act(() => { - useAppStore.setState({ - hasRegisteredPro: false, - hasSavedProCredential: false, - isProActive: false, - proDeviceAdmission: 'unknown', - }); - }); - - expect(resetRoot).toHaveBeenCalledTimes(1); - expect(resetRoot).toHaveBeenCalledWith({ - index: 0, - routes: [{ name: 'ProDetail' }], - }); - }); -}); diff --git a/__tests__/integration/pro/proPricingSettingsEntry.rendered.test.tsx b/__tests__/integration/pro/proPricingSettingsEntry.rendered.test.tsx deleted file mode 100644 index f5a3c7874..000000000 --- a/__tests__/integration/pro/proPricingSettingsEntry.rendered.test.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Pro pricing on screen, reached from Settings. - * - * SPEC (OGAM user's view): the Pro screen states the current founder price - $119 for a lifetime - * licence or $7.99 a month - and never an older tier ($49, $69) or a yearly plan ("/yr", "year"). - * Pro has no yearly plan; it has a lifetime purchase and a monthly subscription. - * - * Journey: the REAL App boots over the native boundary as a returning user (onboarding done on an - * earlier launch, persisted on disk), skips model setup, taps the real Settings tab, then opens Pro - * through each of Settings' two Pro entry points: the "Off Grid AI PRO" row and the upsell banner's - * "Get Pro" button. - */ -import { - installNativeBoundary, - requireRTL, -} from '../../harness/nativeBoundary'; - -// The real navigation library, so a Settings row really pushes its screen (jest.setup stubs -// useNavigation globally). -jest.unmock('@react-navigation/native'); - -jest.mock( - 'react-native-bootsplash', - () => ({ hide: jest.fn(async () => {}) }), - { virtual: true }, -); - -const STALE_PRICE_COPY = [/\$49/, /\$69/, /\/yr/i, /year/i]; - -type DevGlobal = { __DEV__?: boolean }; -const originalDev = (global as DevGlobal).__DEV__; -afterEach(() => { - (global as DevGlobal).__DEV__ = originalDev; -}); - -async function openSettingsAsReturningUser() { - installNativeBoundary(); - // A shipped build: the pricing a customer sees, without development-only Pro access. - (global as DevGlobal).__DEV__ = false; - const React = require('react'); - const rtl = requireRTL(); - const AsyncStorage = require('@react-native-async-storage/async-storage'); - await AsyncStorage.clear(); - await AsyncStorage.setItem( - 'local-llm-app-storage', - JSON.stringify({ state: { hasCompletedOnboarding: true }, version: 0 }), - ); - - const App = require('../../../App').default; - const view = rtl.render(React.createElement(App)); - await rtl.waitFor(() => expect(view.queryByTestId('app-loading')).toBeNull(), { - timeout: process.env.CI === 'true' ? 45_000 : 10_000, - }); - await rtl.waitFor(() => expect(view.getByTestId('auto-setup-skip')).toBeTruthy()); - rtl.fireEvent.press(view.getByTestId('auto-setup-skip')); - await rtl.waitFor(() => expect(view.getByTestId('settings-tab')).toBeTruthy()); - rtl.fireEvent.press(view.getByTestId('settings-tab')); - await rtl.waitFor(() => expect(view.getByText('Off Grid AI PRO')).toBeTruthy()); - return { view, rtl }; -} - -type View = Awaited>['view']; - -function expectCurrentPricingOnly(view: View) { - expect(view.getByText(/\$119 lifetime/)).toBeTruthy(); - expect(view.getByText(/\$7\.99\/month/)).toBeTruthy(); - for (const stale of STALE_PRICE_COPY) { - expect(view.queryAllByText(stale)).toHaveLength(0); - } -} - -describe('Pro pricing reached from Settings (rendered, real App)', () => { - it('the "Off Grid AI PRO" row opens Pro showing $119 lifetime and $7.99 a month, no yearly or old tier', async () => { - const { view, rtl } = await openSettingsAsReturningUser(); - - // BEFORE: on Settings, no price is shown yet. - expect(view.queryByText(/\$119/)).toBeNull(); - expect(view.queryByText(/\$7\.99/)).toBeNull(); - - rtl.fireEvent.press(view.getByText('Off Grid AI PRO')); - await rtl.waitFor(() => expect(view.getByText('Intelligence, democratized.')).toBeTruthy()); - - expectCurrentPricingOnly(view); - view.unmount(); - }); - - it('the upsell banner "Get Pro" button opens Pro showing the same current pricing', async () => { - const { view, rtl } = await openSettingsAsReturningUser(); - - expect(view.queryByText(/\$119/)).toBeNull(); - expect(view.queryByText('Intelligence, democratized.')).toBeNull(); - - // The Settings upsell banner (its LIVE SYNC feature grid) carries its own "Get Pro" button. - // Home stays mounted behind the tabs with a "Get Pro" of its own, so scope to the banner. - let banner = view.getByText('LIVE SYNC').parent; - while (banner && rtl.within(banner).queryAllByText('Get Pro').length === 0) { - banner = banner.parent; - } - expect(banner).not.toBeNull(); - rtl.fireEvent.press(rtl.within(banner!).getByText('Get Pro')); - await rtl.waitFor(() => expect(view.getByText('Intelligence, democratized.')).toBeTruthy()); - - expectCurrentPricingOnly(view); - view.unmount(); - }); -}); diff --git a/__tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx b/__tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx deleted file mode 100644 index 932f02090..000000000 --- a/__tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * The Pro pitch has one purchase path and one key-entry path. A second action that offered to use - * another device's licence duplicated the Sync journey and made the purchase screen ambiguous. - * - * This test enters through the real Home screen and real app navigation. Native rendering remains - * supplied by the Jest environment, but every Off Grid screen, store action, and route is real. - */ -import React from 'react'; -import { fireEvent, render } from '@testing-library/react-native'; -import { NavigationContainer } from '@react-navigation/native'; -import { AppNavigator } from '../../../src/navigation/AppNavigator'; -import { useAppStore } from '../../../src/stores/appStore'; -import { createDeviceInfo, createDownloadedModel } from '../../utils/factories'; - -describe('Pro entry from Home', () => { - beforeEach(() => { - const model = createDownloadedModel(); - const app = useAppStore.getState(); - - // Use the production store actions to restore a returning user's local app state. The behavior - // under test starts with their real Home-screen gesture below. - app.setOnboardingComplete(true); - app.setDeviceInfo(createDeviceInfo()); - app.setDownloadedModels([model]); - app.setActiveModelId(model.id); - }); - - afterEach(() => { - const app = useAppStore.getState(); - app.setActiveModelId(null); - app.setDownloadedModels([]); - app.setOnboardingComplete(false); - }); - - it('opens the Pro screen with its two valid actions and no device-licence shortcut', () => { - const ui = render( - - - , - ); - - fireEvent.press(ui.getByLabelText('Open Off Grid AI Pro')); - - expect(ui.getByText('Off Grid AI Pro')).toBeTruthy(); - expect(ui.getAllByText('Get Pro').length).toBeGreaterThan(0); - expect(ui.getByText('I have a license key')).toBeTruthy(); - expect(ui.queryByText('Use Pro from another device')).toBeNull(); - - ui.unmount(); - }); -}); diff --git a/__tests__/integration/releaseCandidateE.rendered.test.tsx b/__tests__/integration/releaseCandidateE.rendered.test.tsx index 5592e7281..a6dce7f8d 100644 --- a/__tests__/integration/releaseCandidateE.rendered.test.tsx +++ b/__tests__/integration/releaseCandidateE.rendered.test.tsx @@ -69,7 +69,7 @@ async function launch(options: { } await storage.setItem('@local_llm/downloaded_models', JSON.stringify([model])); await storage.setItem('local-llm-app-storage', JSON.stringify({ state: { - hasCompletedOnboarding: true, checklistDismissed: true, downloadedModels: [model], devProDisabled: !options.pro, + hasCompletedOnboarding: true, checklistDismissed: true, downloadedModels: [model], generatedImages: images, settings: { autoDiscoverRemoteModels: false }, }, version: 0 })); await storage.setItem('local-llm-chat-storage', JSON.stringify({ state: { @@ -304,21 +304,16 @@ it('M16: a stale downloaded voice record does not offer Speak when its files are await require('../../pro').deactivate(); }); -it('M15: the Pro purchase action opens the canonical payment page', async () => { +it('M15: the feature index is free to open and sells nothing', async () => { + // Off Grid AI is free, so the screen reached from Settings has no price, no checkout and no licence-key + // field. What it must still do is open, from the same entry point it always had. const app = await launch(); - const { Linking } = require('react-native'); - const original = Linking.openURL; - let browserAddress = ''; - Linking.openURL = async (url: string) => { browserAddress = url; }; - try { - app.rtl.fireEvent.press(app.view.getByTestId('settings-tab')); - app.rtl.fireEvent.press(await app.view.findByText('Off Grid AI PRO')); - app.rtl.fireEvent.press(await app.view.findByRole('button', { name: 'Get Pro' })); - await app.rtl.waitFor(() => expect(browserAddress).toMatch(/^https:\/\/getoffgridai\.co\/pay\/\?/)); - } finally { - Linking.openURL = original; - app.view.unmount(); - } + app.rtl.fireEvent.press(app.view.getByTestId('settings-tab')); + app.rtl.fireEvent.press(await app.view.findByText('Pro features')); + await app.rtl.waitFor(() => expect(app.view.getByText('Everything is free.')).toBeTruthy()); + expect(app.view.queryByText('Get Pro')).toBeNull(); + expect(app.view.queryByText(/\$119|\$7\.99|lifetime/i)).toBeNull(); + app.view.unmount(); }); /** Desktop's model-management and asynchronous image-job HTTP boundary. */ diff --git a/__tests__/rntl/components/ProAhaSheet.test.tsx b/__tests__/rntl/components/ProAhaSheet.test.tsx deleted file mode 100644 index d897c8785..000000000 --- a/__tests__/rntl/components/ProAhaSheet.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react-native'; -import { ProAhaSheet } from '../../../src/components/ProAhaSheet'; - -describe('ProAhaSheet remote media value', () => { - it('shows the work a named Desktop can do for this phone', () => { - const ui = render( - undefined} onRegister={() => undefined} />, - ); - - expect(ui.getByText('Create images with the model active on your Desktop')).toBeTruthy(); - expect(ui.getByText('Transcribe speech with the model active on your Desktop')).toBeTruthy(); - expect(ui.getByText('Hear replies in the voice active on your Desktop')).toBeTruthy(); - expect(ui.getByText('Control which models your named Desktop serves')).toBeTruthy(); - }); -}); diff --git a/__tests__/rntl/screens/ProDetailScreen.test.tsx b/__tests__/rntl/screens/ProDetailScreen.test.tsx deleted file mode 100644 index eb296e4f3..000000000 --- a/__tests__/rntl/screens/ProDetailScreen.test.tsx +++ /dev/null @@ -1,329 +0,0 @@ -/** - * ProDetailScreen Tests - * - * Covers the license-key activation flow (paste key → activate → success card), - * the "Get Pro" → web pay page path, and the Pro-active management section. - */ - -import React from 'react'; -import { Alert, Linking } from 'react-native'; -import { render, fireEvent, waitFor } from '@testing-library/react-native'; -import { projectPersonalMeshActivationFailure } from '@offgrid/sync'; -import { useAppStore } from '../../../src/stores/appStore'; -import { - OFF_GRID_DESKTOP_BENEFIT, - OFF_GRID_DESKTOP_URL, -} from '../../../src/constants'; -import { withUtm } from '../../../src/utils/utm'; - -const PAY_URL = 'https://offgridmobileai.co/pay'; -const mockActivateProByKey = jest.fn(); -const mockGetProLicenseInfo = jest.fn(); -const mockListProDevices = jest.fn(); -const mockDeactivateProDevice = jest.fn(); - -jest.mock('../../../src/services/proLicenseService', () => ({ - activateProByKey: (...args: unknown[]) => mockActivateProByKey(...args), - getProLicenseInfo: (...args: unknown[]) => mockGetProLicenseInfo(...args), - listProDevices: (...args: unknown[]) => mockListProDevices(...args), - deactivateProDevice: (...args: unknown[]) => mockDeactivateProDevice(...args), - // ProManageSection renders the status line from this map — mirror the real export - // so the mock can't diverge (an omitted map made PRO_TIER_META[tier] throw). - PRO_TIER_META: { - lifetime: { label: 'Lifetime', renews: false }, - yearly: { label: 'Yearly', renews: true }, - }, - PRO_PAY_PAGE_URL: 'https://offgridmobileai.co/pay', -})); - -import { ProDetailScreen } from '../../../src/screens/ProDetailScreen'; -import { ProUnlockModal } from '../../../src/screens/ProDetailScreen/ProUnlockModal'; - -/** - * PARTIALLY GREEN, and the four that remain red are red for one reason: this suite mocks - * `proLicenseService`, which is our own code. The assertions therefore describe the mock rather than the - * app, which is how they came to drift without anything failing - the module could not even be resolved - * for a while and every test in the file was skipped in silence. - * - * The remaining four assert the management card (status line, renewal link, replacement error) through - * that mock. Fixing them means running the real licence stack over the in-memory provider, the way the - * sync journeys do, rather than teaching the mock new tricks. - */ -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); - // Defaults for the Pro-active management section. - mockGetProLicenseInfo.mockResolvedValue({ - isPro: true, - tier: 'lifetime', - expiry: null, - verifiedAt: 0, - }); - mockListProDevices.mockResolvedValue([]); - mockDeactivateProDevice.mockResolvedValue(true); - }); - - afterEach(() => { - alertSpy.mockRestore(); - linkingSpy.mockRestore(); - }); - - it('renders the Get Pro call-to-action when the user is not Pro', () => { - const { queryAllByText, queryByText } = render(); - expect(queryAllByText('Get Pro').length).toBeGreaterThan(0); - expect(queryByText('Use Pro from another device')).toBeNull(); - }); - - it('Get Pro opens the web pay page directly without a modal', () => { - const { getAllByText, queryByText } = render(); - fireEvent.press(getAllByText('Get Pro')[0]); - expect(linkingSpy).toHaveBeenCalledWith(withUtm(PAY_URL, 'pro-detail')); - // No in-app activation step for paying. - expect(queryByText('Enter your license key')).toBeNull(); - }); - - it('links to Off Grid AI Desktop from the Pro pitch', () => { - const { getByText, queryByText } = render(); - expect(getByText(OFF_GRID_DESKTOP_BENEFIT)).toBeTruthy(); - expect(queryByText(/building this through July/i)).toBeNull(); - fireEvent.press(getByText('Get Off Grid AI Desktop')); - expect(linkingSpy).toHaveBeenCalledWith( - withUtm(OFF_GRID_DESKTOP_URL, 'pro-detail'), - ); - }); - - it('shows the remote media outcomes and named Desktop control', () => { - const { getByText } = render(); - expect(getByText('Your Desktop does the heavy work')).toBeTruthy(); - expect( - getByText( - 'Create images, transcribe speech, and hear replies with the models active on your named Desktop. You choose which models it serves.', - ), - ).toBeTruthy(); - }); - - it('shows the Off Grid AI Desktop link to Pro-active users too', async () => { - useAppStore.setState({ hasRegisteredPro: true }); - const { getByText } = render(); - await waitFor(() => - expect(getByText('Get Off Grid AI Desktop')).toBeTruthy(), - ); - fireEvent.press(getByText('Get Off Grid AI Desktop')); - expect(linkingSpy).toHaveBeenCalledWith( - withUtm(OFF_GRID_DESKTOP_URL, 'pro-detail'), - ); - }); - - it('"I have a license key" opens the activation modal', () => { - const { getByText } = render(); - fireEvent.press(getByText('I have a license key')); - expect(getByText('Enter your license key')).toBeTruthy(); - expect( - getByText( - 'Paste the license key from your email. It works on up to 5 devices.', - ), - ).toBeTruthy(); - }); - - it('activates the license key and shows the success card', async () => { - mockActivateProByKey.mockResolvedValueOnce({ ok: true }); - const { getByText, getByTestId } = render(); - fireEvent.press(getByText('I have a license key')); - fireEvent.changeText(getByTestId('license-key-input'), 'key/abc123'); - fireEvent.press(getByTestId('unlock-cta')); - await waitFor(() => - expect(mockActivateProByKey).toHaveBeenCalledWith('key/abc123'), - ); - await waitFor(() => expect(getByText('Pro activated')).toBeTruthy()); - }); - - it('keeps an in-flight activation and its result visible if the parent closes', async () => { - let finishActivation!: (result: { ok: true }) => void; - mockActivateProByKey.mockImplementationOnce( - () => - new Promise(resolve => { - finishActivation = resolve; - }), - ); - const onClose = jest.fn(); - const onUnlocked = jest.fn(); - const ui = render( - , - ); - fireEvent.changeText(ui.getByTestId('license-key-input'), 'key/abc123'); - fireEvent.press(ui.getByTestId('unlock-cta')); - expect(ui.getByText('Activating...')).toBeTruthy(); - - ui.rerender( - , - ); - expect(ui.getByText('Activating...')).toBeTruthy(); - - finishActivation({ ok: true }); - await waitFor(() => expect(ui.getByText('Pro activated')).toBeTruthy()); - expect(onUnlocked).toHaveBeenCalledTimes(1); - expect(onClose).not.toHaveBeenCalled(); - }); - - it('lets the user dismiss the success card with Got it', async () => { - mockActivateProByKey.mockResolvedValueOnce({ ok: true }); - const { getByText, getByTestId, queryByText } = render(); - fireEvent.press(getByText('I have a license key')); - fireEvent.changeText(getByTestId('license-key-input'), 'key/abc123'); - fireEvent.press(getByTestId('unlock-cta')); - await waitFor(() => expect(getByText('Pro activated')).toBeTruthy()); - fireEvent.press(getByText('Got it')); - await waitFor(() => expect(queryByText('Pro activated')).toBeNull()); - }); - - it('shows an inline error when the key is invalid', async () => { - // 'invalid' is not a reason the activation flow reports any more. The failure codes are named for - // what went wrong - a key the provider will not accept is `invalid_credential` - and the sentence the - // user reads comes from the shared projection rather than from this screen. - mockActivateProByKey.mockResolvedValueOnce({ - ok: false, - reason: 'invalid_credential', - }); - const { getByText, getByTestId } = render(); - fireEvent.press(getByText('I have a license key')); - fireEvent.changeText(getByTestId('license-key-input'), 'key/nope'); - fireEvent.press(getByTestId('unlock-cta')); - await waitFor(() => - expect(getByText('That license key is invalid or revoked.')).toBeTruthy(), - ); - expect(getByText('License not accepted')).toBeTruthy(); - }); - - it.each([ - ['a licence with no room left', 'capacity_full'], - ['a seat that could not be freed', 'replacement_failed'], - ['a licence the provider will not accept', 'invalid_credential'], - ])('says what went wrong for %s', async (_why, reason) => { - mockActivateProByKey.mockResolvedValueOnce({ ok: false, reason }); - const { getByText, getByTestId } = render(); - fireEvent.press(getByText('I have a license key')); - fireEvent.changeText(getByTestId('license-key-input'), 'key/abc123'); - fireEvent.press(getByTestId('unlock-cta')); - - // Read from the shared projection rather than restated here. The test used to pass reason: 'limit' - // - not a code the app has - and assert copy from a different failure, so it was checking the words - // of one error against the code of another. Asserting through the projection means the screen and - // this test cannot disagree, and a copy change cannot silently pass. - const expected = projectPersonalMeshActivationFailure( - reason as Parameters[0], - ); - await waitFor(() => expect(getByText(expected.title)).toBeTruthy()); - expect(getByText(expected.description)).toBeTruthy(); - }); - - it('keeps the activate button disabled until a key is entered', async () => { - const { getByText, getByTestId } = render(); - fireEvent.press(getByText('I have a license key')); - // Empty input: the disabled button ignores the press, no activate call. - fireEvent.press(getByTestId('unlock-cta')); - expect(mockActivateProByKey).not.toHaveBeenCalled(); - // Once a key is entered the button is enabled and activates. - mockActivateProByKey.mockResolvedValueOnce({ ok: true }); - fireEvent.changeText(getByTestId('license-key-input'), 'key/abc123'); - fireEvent.press(getByTestId('unlock-cta')); - await waitFor(() => expect(mockActivateProByKey).toHaveBeenCalled()); - }); - - it('treats whitespace-only input as empty so the button stays disabled', () => { - const { getByText, getByTestId } = render(); - fireEvent.press(getByText('I have a license key')); - fireEvent.changeText(getByTestId('license-key-input'), ' '); - fireEvent.press(getByTestId('unlock-cta')); - expect(mockActivateProByKey).not.toHaveBeenCalled(); - }); - - it('strips surrounding whitespace before activating', async () => { - mockActivateProByKey.mockResolvedValueOnce({ ok: true }); - const { getByText, getByTestId } = render(); - fireEvent.press(getByText('I have a license key')); - fireEvent.changeText(getByTestId('license-key-input'), ' key/abc123 '); - fireEvent.press(getByTestId('unlock-cta')); - await waitFor(() => - expect(mockActivateProByKey).toHaveBeenCalledWith('key/abc123'), - ); - }); - - it('activates from the keyboard action', async () => { - mockActivateProByKey.mockResolvedValueOnce({ ok: true }); - const { getByText, getByTestId } = render(); - fireEvent.press(getByText('I have a license key')); - fireEvent.changeText(getByTestId('license-key-input'), 'key/abc123'); - fireEvent(getByTestId('license-key-input'), 'submitEditing'); - await waitFor(() => - expect(mockActivateProByKey).toHaveBeenCalledWith('key/abc123'), - ); - }); - - it('"Not a member yet? Get Pro" in the modal opens the pay page', () => { - const { getByText } = render(); - fireEvent.press(getByText('I have a license key')); - fireEvent.press(getByText('Not a member yet? Get Pro')); - expect(linkingSpy).toHaveBeenCalledWith(withUtm(PAY_URL, 'pro-unlock')); - }); - - it('renders the Pro Active state with the management section when Pro is owned', async () => { - // "Pro Active" is about THIS DEVICE being admitted to the licence, not merely about owning one. - // There are only two states now: a device the roster removed is not Pro and sees the buy screen, - // so admission has to be set here alongside the credential or nothing Pro renders at all. - useAppStore.setState({ - hasRegisteredPro: true, - hasSavedProCredential: true, - proDeviceAdmission: 'active' as const, - }); - const { getByText } = render(); - expect(getByText('Pro Active')).toBeTruthy(); - // ProManageSection loads license info async, then shows the status line. - await waitFor(() => - expect(getByText('Lifetime · never expires')).toBeTruthy(), - ); - }); - - it('shows the yearly status line and a Manage subscription link for a recurring license', async () => { - useAppStore.setState({ - hasRegisteredPro: true, - hasSavedProCredential: true, - proDeviceAdmission: 'active' as const, - }); - mockGetProLicenseInfo.mockResolvedValue({ - isPro: true, - tier: 'yearly', - expiry: '2026-08-01T00:00:00.000Z', - verifiedAt: 0, - }); - const { getByText } = render(); - await waitFor(() => expect(getByText(/Yearly · renews/)).toBeTruthy()); - expect(getByText('Manage subscription')).toBeTruthy(); - }); - - it('shows a lifetime status line and NO Manage subscription link for a one-time license', async () => { - useAppStore.setState({ hasRegisteredPro: true, hasSavedProCredential: true }); - mockGetProLicenseInfo.mockResolvedValue({ - isPro: true, - tier: 'lifetime', - expiry: null, - verifiedAt: 0, - }); - const { getByText, queryByText } = render(); - await waitFor(() => - expect(getByText(/Lifetime · never expires/)).toBeTruthy(), - ); - expect(queryByText('Manage subscription')).toBeNull(); - }); -}); diff --git a/__tests__/rntl/screens/ProFeaturesScreen.test.tsx b/__tests__/rntl/screens/ProFeaturesScreen.test.tsx new file mode 100644 index 000000000..8660f8dbb --- /dev/null +++ b/__tests__/rntl/screens/ProFeaturesScreen.test.tsx @@ -0,0 +1,66 @@ +/** + * The Pro features screen sells nothing. + * + * SPEC (OGAM user's view): the screen every Pro entry point opens — the Home crown, Settings, the Tools + * page, the chat assistant gate — states that everything is free, and offers no price, no checkout, no + * licence-key field and no subscription. What it can still do is be honest about the BUILD: a build + * without the optional pro bundle says so, rather than promising features that are not compiled in. + * + * The bundle is represented the way the app represents it — by registering its screens — so this suite + * exercises the same signal the rest of the app reads (useIsProActive → the screen registry). + */ +import React from 'react'; +import { Linking } from 'react-native'; +import { render, fireEvent } from '@testing-library/react-native'; +import { OFF_GRID_DESKTOP_BENEFIT } from '../../../src/constants'; +import { + registerScreen, + _clearScreensForTesting, +} from '../../../src/navigation/screenRegistry'; +import { PRO_TOOLS_SCREEN } from '../../../src/hooks/useIsProActive'; +import { ProDetailScreen } from '../../../src/screens/ProDetailScreen'; + +describe('the Pro features screen', () => { + beforeEach(() => { + _clearScreensForTesting(); + }); + afterEach(() => { + _clearScreensForTesting(); + }); + + it('says everything is free, and asks for nothing', () => { + const { getByText, queryByText } = render(); + + expect(getByText('Everything is free.')).toBeTruthy(); + expect(queryByText('Get Pro')).toBeNull(); + expect(queryByText('I have a license key')).toBeNull(); + expect(queryByText(/Enter your license key/)).toBeNull(); + expect(queryByText(/\$119|\$7\.99|per month|lifetime/i)).toBeNull(); + }); + + it('explains an absent bundle instead of selling it', () => { + const { getByText } = render(); + expect(getByText(/does not include the optional Pro bundle/)).toBeTruthy(); + }); + + it('marks this build included once the bundle has registered its screens', () => { + registerScreen({ name: PRO_TOOLS_SCREEN, component: () => null }); + + const { getByText, queryByText } = render(); + + expect(getByText('Included')).toBeTruthy(); + expect(queryByText(/does not include the optional Pro bundle/)).toBeNull(); + }); + + it('still points at the free Desktop companion', () => { + const spy = jest.spyOn(Linking, 'openURL').mockResolvedValue(true as never); + const { getByText } = render(); + + fireEvent.press(getByText(OFF_GRID_DESKTOP_BENEFIT)); + + expect(spy).toHaveBeenCalledWith( + expect.stringContaining('getoffgridai.co/desktop'), + ); + spy.mockRestore(); + }); +}); diff --git a/__tests__/rntl/screens/SettingsScreen.test.tsx b/__tests__/rntl/screens/SettingsScreen.test.tsx index 1930ba0e2..c8f59bc69 100644 --- a/__tests__/rntl/screens/SettingsScreen.test.tsx +++ b/__tests__/rntl/screens/SettingsScreen.test.tsx @@ -55,9 +55,6 @@ jest.mock('../../../src/components/AnimatedListItem', () => ({ const mockSetOnboardingComplete = jest.fn(); const mockSetThemeMode = jest.fn(); const mockCompleteChecklistStep = jest.fn(); -// Mutated per-test to drive Pro banner visibility. `mock`-prefixed so jest.mock's -// hoisted factory is allowed to reference it. -const mockProState = { hasRegisteredPro: false, proBannerDismissed: false }; jest.mock('../../../src/stores', () => ({ useAppStore: Object.assign(jest.fn((selector?: any) => { const state = { @@ -65,9 +62,7 @@ jest.mock('../../../src/stores', () => ({ themeMode: 'system', setThemeMode: mockSetThemeMode, completeChecklistStep: mockCompleteChecklistStep, - setProBannerDismissed: jest.fn(), - hasRegisteredPro: mockProState.hasRegisteredPro, - proBannerDismissed: mockProState.proBannerDismissed, + hasRegisteredPro: false, }; return selector ? selector(state) : state; }), { @@ -98,21 +93,15 @@ jest.mock('@react-navigation/native', () => ({ describe('SettingsScreen', () => { beforeEach(() => { jest.clearAllMocks(); - mockProState.hasRegisteredPro = false; - mockProState.proBannerDismissed = false; }); - it('shows the Pro upsell banner when Pro is not active and not dismissed', () => { - const { getByText } = render(); - expect( - getByText('Your private AI stays current across your devices with live sync.'), - ).toBeTruthy(); - }); - - it('hides the Pro upsell banner once Pro is active', () => { - mockProState.hasRegisteredPro = true; + it('sells nothing: no upsell banner and no purchase pitch', () => { + // Off Grid AI is free, so Settings carries no promo banner and no price. const { queryByText } = render(); - expect(queryByText(/democratized/i)).toBeNull(); + expect(queryByText('Get Pro')).toBeNull(); + expect( + queryByText('Your private AI stays current across your devices with live sync.'), + ).toBeNull(); }); it('renders "Settings" title', () => { diff --git a/__tests__/rntl/screens/ToolsScreen.test.tsx b/__tests__/rntl/screens/ToolsScreen.test.tsx index 757d0858b..f900f3263 100644 --- a/__tests__/rntl/screens/ToolsScreen.test.tsx +++ b/__tests__/rntl/screens/ToolsScreen.test.tsx @@ -4,7 +4,7 @@ * The tools picker is a full page (it replaced the old bottom-sheet drawer). Tests: * - Renders the page header and all free tool rows * - Pro Tools row is pinned to the top of the listing and is shown to everyone - * - Pro Tools routing: free -> ProDetail upsell, pro -> the Pro Tools screen + * - Pro Tools routing: no bundle -> the Pro features screen, bundle -> the Pro Tools screen * - Tool toggles flow through the core settings store * - Back button navigates back */ @@ -95,13 +95,13 @@ describe('ToolsScreen', () => { expect(getByText('Pro Tools')).toBeTruthy(); }); - it('routes a free user to the Pro upsell when Pro Tools is pressed', () => { + it('routes to the Pro features screen when this build has no Pro bundle', () => { const { getByTestId } = render(); fireEvent.press(getByTestId('tools-pro-tools')); expect(mockNavigate).toHaveBeenCalledWith('ProDetail'); }); - it('routes a pro user straight to the Pro Tools screen', () => { + it('routes straight to the Pro Tools screen when the bundle is in this build', () => { useAppStore.setState({ hasRegisteredPro: true, hasSavedProCredential: true, diff --git a/__tests__/unit/hooks/useIsProActive.test.tsx b/__tests__/unit/hooks/useIsProActive.test.tsx index de8343366..afcf65e2d 100644 --- a/__tests__/unit/hooks/useIsProActive.test.tsx +++ b/__tests__/unit/hooks/useIsProActive.test.tsx @@ -19,8 +19,9 @@ const Probe = () => { describe('useIsProActive / useHasRegisteredScreen', () => { beforeEach(() => { _clearScreensForTesting(); - // Registration alone no longer means Pro: the device must still be entitled, or a device the owner - // removed from the licence would keep every Pro entry point until the app restarted. + // Registration is the whole answer now: it says the bundle is in this build, which is all that can + // differ. The licence flags below are the honest record of what this install holds, and no longer + // change what any of it may do. useAppStore.setState({ hasRegisteredPro: true, hasSavedProCredential: true, diff --git a/__tests__/unit/hooks/useOpenProTools.test.tsx b/__tests__/unit/hooks/useOpenProTools.test.tsx index 388371c6a..8359e2a77 100644 --- a/__tests__/unit/hooks/useOpenProTools.test.tsx +++ b/__tests__/unit/hooks/useOpenProTools.test.tsx @@ -2,8 +2,11 @@ * useOpenProTools — the single "open the Pro Tools destination" callback shared by * the chat quick-settings row and the Tools page. It routes on whether the Pro * Tools screen is registered (the same signal as useIsProActive): - * - free (screen not registered) -> ProDetail upsell - * - pro (screen registered) -> the Pro Tools screen + * - no bundle (screen not registered) -> the Pro features screen, which explains why + * - bundle (screen registered) -> the Pro Tools screen + * + * This used to fork on entitlement, with the non-entitled branch going to the purchase + * page. There is no purchase page: the only thing that can be missing is the code. */ import React from 'react'; @@ -47,7 +50,7 @@ describe('useOpenProTools', () => { _clearScreensForTesting(); }); - it('routes a free user to the Pro upsell', () => { + it('routes to the Pro features screen when the bundle is not in this build', () => { const { getByTestId } = render(); fireEvent.press(getByTestId('open')); expect(mockNavigate).toHaveBeenCalledWith('ProDetail'); @@ -66,7 +69,9 @@ describe('useOpenProTools', () => { expect(mockNavigate).toHaveBeenCalledWith(PRO_TOOLS_SCREEN); }); - it('routes to the purchase screen as soon as live access is removed', () => { + it('still opens the tools for a device with no credential and no roster answer', () => { + // Nothing bought, nothing admitted: the tools are registered, so they open. This is the case that + // used to be routed to a purchase. registerScreen({ name: PRO_TOOLS_SCREEN, component: () => null }); useAppStore.setState({ hasRegisteredPro: false, @@ -78,6 +83,6 @@ describe('useOpenProTools', () => { const { getByTestId } = render(); fireEvent.press(getByTestId('open')); - expect(mockNavigate).toHaveBeenCalledWith('ProDetail'); + expect(mockNavigate).toHaveBeenCalledWith(PRO_TOOLS_SCREEN); }); }); diff --git a/__tests__/unit/navigation/useProExpiryRedirect.test.tsx b/__tests__/unit/navigation/useProExpiryRedirect.test.tsx deleted file mode 100644 index a4ac3e815..000000000 --- a/__tests__/unit/navigation/useProExpiryRedirect.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react-native'; -import type { NavigationContainerRefWithCurrent } from '@react-navigation/native'; -import { useProExpiryRedirect } from '../../../src/navigation/useProExpiryRedirect'; -import type { RootStackParamList } from '../../../src/navigation/types'; -import { useAppStore } from '../../../src/stores/appStore'; - -describe('the expired Pro purchase route', () => { - const navigation = { - isReady: jest.fn(() => true), - getCurrentRoute: jest.fn(() => ({ name: 'Main', key: 'main' })), - resetRoot: jest.fn(), - } as unknown as NavigationContainerRefWithCurrent; - - beforeEach(() => { - jest.clearAllMocks(); - useAppStore.getState().setHasExpiredProCredential(false); - }); - - afterEach(() => { - useAppStore.getState().setHasExpiredProCredential(false); - }); - - it('routes a cold-start expired credential when navigation becomes ready', async () => { - useAppStore.getState().setHasExpiredProCredential(true); - const { result } = renderHook(() => useProExpiryRedirect(navigation)); - - act(() => result.current()); - - await waitFor(() => - expect(navigation.resetRoot).toHaveBeenCalledWith({ - index: 0, - routes: [{ name: 'ProDetail' }], - }), - ); - }); - - it('routes an active screen when the saved deadline passes', async () => { - renderHook(() => useProExpiryRedirect(navigation)); - expect(navigation.resetRoot).not.toHaveBeenCalled(); - - act(() => { - useAppStore.getState().setHasExpiredProCredential(true); - }); - - await waitFor(() => - expect(navigation.resetRoot).toHaveBeenCalledWith({ - index: 0, - routes: [{ name: 'ProDetail' }], - }), - ); - }); -}); diff --git a/__tests__/unit/services/proPrompt.test.ts b/__tests__/unit/services/proPrompt.test.ts deleted file mode 100644 index 35fae80c3..000000000 --- a/__tests__/unit/services/proPrompt.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Unit tests for proPrompt.ts - */ - -jest.mock('../../../src/stores/appStore', () => ({ - useAppStore: { getState: jest.fn() }, -})); - -import { - shouldShowProAha, - subscribeProPrompt, - emitProPrompt, - checkProPromptForText, - checkProPromptForImage, -} from '../../../src/services/proPrompt'; -import { useAppStore } from '../../../src/stores/appStore'; - -const mockedGetState = useAppStore.getState as jest.Mock; - -function makeStore(overrides: any = {}) { - return { - hasRegisteredPro: false, - isProActive: false, - proAhaTriggeredBy: null, - textGenerationCount: 10, - imageGenerationCount: 10, - setProAhaTriggeredBy: jest.fn(), - ...overrides, - }; -} - -describe('shouldShowProAha', () => { - it('returns true at threshold (10)', () => { - expect(shouldShowProAha(10)).toBe(true); - }); - - it('returns false below threshold', () => { - expect(shouldShowProAha(2)).toBe(false); - expect(shouldShowProAha(1)).toBe(false); - expect(shouldShowProAha(9)).toBe(false); - }); - - it('returns false after the one automatic offer', () => { - expect(shouldShowProAha(4)).toBe(false); - expect(shouldShowProAha(11)).toBe(false); - expect(shouldShowProAha(14)).toBe(false); - expect(shouldShowProAha(15)).toBe(false); - expect(shouldShowProAha(16)).toBe(false); - expect(shouldShowProAha(24)).toBe(false); - expect(shouldShowProAha(25)).toBe(false); - expect(shouldShowProAha(35)).toBe(false); - }); -}); - -describe('subscribeProPrompt / emitProPrompt', () => { - it('calls listener when emitted', () => { - const listener = jest.fn(); - const unsub = subscribeProPrompt(listener); - emitProPrompt('text'); - expect(listener).toHaveBeenCalledWith('text'); - unsub(); - }); - - it('does not call listener after unsubscribe', () => { - const listener = jest.fn(); - const unsub = subscribeProPrompt(listener); - unsub(); - emitProPrompt('image'); - expect(listener).not.toHaveBeenCalled(); - }); -}); - -describe('checkProPromptForText', () => { - beforeEach(() => jest.useFakeTimers()); - afterEach(() => jest.useRealTimers()); - - it('skips when hasRegisteredPro=true', () => { - const store = makeStore({ hasRegisteredPro: true }); - mockedGetState.mockReturnValue(store); - checkProPromptForText(0); - expect(store.setProAhaTriggeredBy).not.toHaveBeenCalled(); - }); - - it('skips when isProActive=true (keychain/dev-unlocked Pro user, never registered)', () => { - const store = makeStore({ hasRegisteredPro: false, isProActive: true }); - mockedGetState.mockReturnValue(store); - checkProPromptForText(0); - expect(store.setProAhaTriggeredBy).not.toHaveBeenCalled(); - }); - - it('skips when proAhaTriggeredBy is already set', () => { - const store = makeStore({ proAhaTriggeredBy: 'text' }); - mockedGetState.mockReturnValue(store); - checkProPromptForText(0); - expect(store.setProAhaTriggeredBy).not.toHaveBeenCalled(); - }); - - it('skips when count does not meet threshold', () => { - const store = makeStore({ textGenerationCount: 1 }); - mockedGetState.mockReturnValue(store); - checkProPromptForText(0); - expect(store.setProAhaTriggeredBy).not.toHaveBeenCalled(); - }); - - it('triggers when all conditions met', () => { - const store = makeStore({ textGenerationCount: 10 }); - mockedGetState.mockReturnValue(store); - checkProPromptForText(0); - expect(store.setProAhaTriggeredBy).toHaveBeenCalledWith('text'); - }); -}); - -describe('checkProPromptForImage', () => { - beforeEach(() => jest.useFakeTimers()); - afterEach(() => jest.useRealTimers()); - - it('triggers for image when count meets threshold', () => { - const store = makeStore({ imageGenerationCount: 10 }); - mockedGetState.mockReturnValue(store); - checkProPromptForImage(0); - expect(store.setProAhaTriggeredBy).toHaveBeenCalledWith('image'); - }); - - it('skips when hasRegisteredPro=true', () => { - const store = makeStore({ hasRegisteredPro: true, imageGenerationCount: 10 }); - mockedGetState.mockReturnValue(store); - checkProPromptForImage(0); - expect(store.setProAhaTriggeredBy).not.toHaveBeenCalled(); - }); -}); diff --git a/__tests__/unit/utils/proPricing.test.ts b/__tests__/unit/utils/proPricing.test.ts deleted file mode 100644 index dfc627a22..000000000 --- a/__tests__/unit/utils/proPricing.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getPricingCopy } from '../../../src/utils/proPricing'; - -describe('getPricingCopy', () => { - const copy = getPricingCopy(); - - it('offers the two plans sold on getoffgridai.co - $119 lifetime and $7.99/month - and no yearly', () => { - expect(copy.title).toBe('$119 lifetime or $7.99/month'); - expect(copy.sheetSubheadline).toMatch(/\$119 once/); - expect(copy.sheetSubheadline).toMatch(/\$7\.99 a month/); - // The yearly plan is not sold; it must not resurface anywhere in the copy. - const all = Object.values(copy).join(' '); - expect(all).not.toMatch(/\byear(ly)?\b|\/yr/i); - expect(all).not.toMatch(/\$49|\$39|\$69/); - }); - - it('keeps the Get Pro CTA (the web pay-page trigger the Pro surfaces assert)', () => { - expect(copy.cta).toBe('Get Pro'); - expect(copy.label).toBe('FOUNDER RATE'); - }); - - it('states the founder-rate terms (locked in, only goes up) and the 5-device cap', () => { - expect(copy.subtitle).toMatch(/only goes up/i); - expect(copy.subtitle).toMatch(/5 devices/); - }); -}); diff --git a/docs/pro-licensing-plan.md b/docs/pro-licensing-plan.md index b9f068846..66320561d 100644 --- a/docs/pro-licensing-plan.md +++ b/docs/pro-licensing-plan.md @@ -1,160 +1,46 @@ -# Off Grid Pro - Licensing Plan +# Off Grid Pro licensing — retired -## What it is +**Status: retired. There is nothing to purchase.** -Off Grid Pro uses a license-key model: no login, no accounts, offline-first. -RevenueCat handles the money (checkout + payment events, as it does today). -Keygen handles licensing (keys, device limits, offline validation). A thin -Lambda connects the two. The app validates a signed key offline and never talks -to RevenueCat. +Off Grid AI no longer has a paid tier. Every capability the app has — voice mode and on-device speech, +custom personas, draft-then-approve actions, MCP tools, and Personal Mesh sync — is free for everyone, in +every build. There is no price, no checkout, no licence key, and nothing that expires. -## The model +## What this document used to describe -| Tier | Price | Key type | Expiry | Renewal | -|---|---|---|---|---| -| Lifetime Pro | $50 one-time | perpetual | never | none | -| Monthly Pro | $39 / month | timed | period end + buffer | each cycle | +The licensing system was a three-part machine: -Both run through one Keygen integration. The only difference is the expiry baked -into the key and whether renewals fire. +1. **RevenueCat** took the money and fired a webhook on payment, renewal and cancellation. +2. **A stateless Lambda** turned those webhooks into Keygen licence creations and renewals, and emailed + the key to the buyer. +3. **Keygen** issued Ed25519-signed keys with a tier and an expiry, capped at five machines, and served + offline validation, check-in, and a grace window. -## The pieces +The app validated the signed key locally against a baked-in public key, activated once per device, and +re-checked periodically to catch renewal or revocation. A device the licensed-device roster dropped lost +its Pro surfaces at the next revalidation. -1. **RevenueCat - payments.** The existing pay page and payment backend. On a - successful payment it fires a webhook with the buyer's email and product. No - change to how money is taken. -2. **Lambda - the bridge (stateless, no database).** Receives RevenueCat - webhooks, calls Keygen to create or renew the license, and emails the key. -3. **Keygen Cloud - licensing.** Issues cryptographically signed keys with tier - and expiry embedded; enforces the device cap; serves offline validation, the - check-in, and the grace window. -4. **The app - offline validation.** Verifies the signed key locally against a - baked-in public key; activates once per device; re-checks periodically to - catch renewal and revocation. +## What remains, and why -## Keygen configuration +The key-side plumbing was not deleted wholesale, because two things still need it: -- **Two policies:** `pro-lifetime` (perpetual, no expiry) and `pro-monthly` - (timed, 1 month plus a short buffer). -- Both: **floating, `maxMachines = 5`** (shared across iOS, Android, and Off Grid - Desktop), **Ed25519** signed, machine activation required before validation. -- **License file (cert) TTL ~9 days** - the offline-snapshot freshness, separate - from the subscription expiry. -- Secret key lives server-side (Lambda + migration script). The app holds only - the public key. +- **Personal Mesh pairing** keys a device's identity to its fingerprint, and the roster of known devices + rides on that identity. Syncing between your own devices needs it whether or not anything costs money. +- **Grandfathered credentials.** Someone who bought before the tier was removed still holds a key. The + app can still read, activate, and revalidate it — it simply has no bearing on what they may use. -## The Lambda (issuance + renewal) +So the entitlement service, the provider seam it registers, and the lifecycle that revalidates on launch +and foreground all stay. What was removed is every *decision* made from them: `selectHasProAccess` in +`src/stores/proAccessSlice.ts` grants unconditionally, and the paywall surfaces (pricing copy, the Pro +upsell banner, the Aha offer sheet, the licence-key modal, the subscription management card, and the +expiry redirect) are gone. -Stateless, idempotent (RevenueCat retries on), keyed on the event id and email. +The other half of the old system — the voice, tool and mesh code — lives in the optional `@offgrid/pro` +bundle (the `pro/` submodule). A build either compiles it in, in which case it is unlocked for everyone, +or it does not, in which case the affected surfaces show an empty state that explains the build rather +than offering a purchase. -| RevenueCat event | Keygen action | -|---|---| -| `INITIAL_PURCHASE` / `NON_RENEWING_PURCHASE` (lifetime) | create a **perpetual** license, set `metadata.email`, email the key | -| `INITIAL_PURCHASE` (monthly) | create a **timed** license (expiry = period end + buffer), set `metadata.email`, email the key | -| `RENEWAL` (monthly, each cycle) | push the license expiry forward, proactively | -| `CANCELLATION` / `EXPIRATION` (monthly) | let it lapse at period end (or suspend) | +## If you are looking for pricing -The email comes from the event (`$email`). Sending the key needs an email -provider (SES, Resend, Postmark, etc.). - -## The app - -- **First run on a device:** one online **activation** claims one of the 5 - machine slots. The fingerprint is a random UUID generated once and persisted - in the Keychain, so a reinstall reclaims its slot. Tag the machine with - `metadata.platform = ios | android | desktop`. -- **Every launch:** verify the signed key locally against the baked-in public - key, read tier and expiry, check expiry against the device clock. Fully - offline. -- **Every 7 days:** check in with Keygen to refresh the cert, pick up a renewed - expiry, and catch revocation. If the check-in fails, keep working **2 more - days** (grace, inside the 9-day TTL), then lock. -- **Seat-management screen:** list the device's machines (N of 5) and let the - user deactivate one. A 5-cap on a perpetual key fills over years of device - upgrades, so a lifetime buyer must always be able to free a slot. - -## The monthly expiry rule (the one risk) - -A monthly key expires monthly, so the renewal must reach Keygen before the key -expires or a paying user hits the grace and then locks out. Applied together: - -1. **Buffer the expiry**: license expiry = billing period end + a few days, so - the offline key outlives the period enough to absorb webhook and check-in lag. -2. **Renew proactively** on `RENEWAL` (push expiry forward immediately). -3. Idempotent Lambda + RevenueCat retries. -4. A manual reissue/extend support path for the rare miss. - -Lifetime has none of this risk - the key never expires. - -## Migration (existing cohort + cutover) - -**Backfill (one-time script, same Keygen calls as the Lambda in a loop):** - -1. Export the current Pro customers from RevenueCat (dashboard export or REST - API): email, tier, status. -2. For each: create a Keygen license - **perpetual** for lifetime/pre-order - buyers, **timed** for any monthly - set `metadata.email`, email the key. -3. Idempotent: skip anyone who already has a license with that `metadata.email`. - -**Cutover (do not strand anyone):** - -- The new app build validates the Keygen key. -- Keep the current in-app entitlement check working as a **fallback for a 60-90 - day window** so users keep Pro between getting the email and entering the key. - Lifetime buyers especially must not lose access in the gap. -- Migration email: "we moved to license keys, here is yours, update the app and - paste it in." -- Remove the fallback after the window. - -## Analytics (email and platform) - -Both live in Keygen: - -- **email - key:** `metadata.email` on each license at issuance. Keygen is the - customer lookup; no separate database. -- **desktop / mobile / both:** `metadata.platform` on each machine at activation. - A small reporting script reads the Keygen API and counts desktop-only vs - mobile-only vs both. Optionally pipe Keygen webhooks into a sheet for a live - view. - -## Cost - -- Keygen Cloud Std 1: $99/mo flat (up to 1,000 active licensed users; ~16% off - annual). -- RevenueCat + Stripe: their normal per-transaction cut. -- Lambda: negligible. -- No database, no servers maintained. - -## Honest limits - -- Offline validation is defeatable by a determined cracker (spoof a fingerprint, - patch the verify call) - true of any client-side licensing. It stops casual - key-sharing cold, because new devices must come online to activate and the - 5-cap holds there. -- Device fingerprints go to Keygen Cloud (a third party): anti-piracy metadata - (a hashed fingerprint + activation timestamps), never user data, touched once - per device. -- Monthly renewal reliability is load-bearing for the monthly tier (see the - expiry rule). Lifetime is unaffected. - -## Build plan - -- **Phase 0 - Keygen setup:** account, two policies, `maxMachines = 5`, Ed25519, - ~9-day TTL. Capture public key (app) + secret key (server). -- **Phase 1 - Lambda:** RevenueCat webhook endpoint, the event mapping above, - email delivery, idempotency, expiry buffer + proactive renewal. -- **Phase 2 - App:** offline verify module, activation with the Keychain-UUID - fingerprint + platform tag, 7-day check-in / 2-day grace, seat-management - screen. -- **Phase 3 - Migration + cutover:** backfill script, migration email, the - 60-90 day fallback window, then removal. -- **Phase 4 - Analytics + ops:** reporting script, manual reissue path, webhook - delivery monitoring. - -## References - -- Keygen floating licenses: https://keygen.sh/docs/choosing-a-licensing-model/floating-licenses/ -- Keygen offline licensing: https://keygen.sh/docs/choosing-a-licensing-model/offline-licenses/ -- Keygen cryptography: https://keygen.sh/docs/api/cryptography/ -- Keygen activating machines: https://keygen.sh/docs/activating-machines/ -- RevenueCat webhooks: https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields +There is none. See the README section **Everything is free**, and `src/services/proLicenseService.ts` for +the note on what the remaining entitlement plumbing is for. diff --git a/rules.md b/rules.md index 02149b70e..a5dcf193a 100644 --- a/rules.md +++ b/rules.md @@ -2,7 +2,7 @@ Follow [the workspace engineering contract](../.codex/ENGINEERING_CONTRACT.md). It takes priority over these repo notes. Keep each change in the smallest existing owner. -- Paid feature code belongs in the private `pro/` submodule. Core uses the existing registration seams. Commit Pro changes in its own repo. +- Optional-feature code (voice, MCP tools, Personal Mesh sync) belongs in the private `pro/` submodule. Core uses the existing registration seams — slots, hooks, screens, settings sections — and every one of them is free: nothing in core gates a feature behind a purchase. Commit bundle changes in its own repo. - For UI work, use `@offgrid/design` and the relevant guide in `docs/design/`. For customer-facing copy, read `docs/brand_tone_voice.md`. - Keep capture opt-in with a visible recording indicator. Do not publish private device data. - During device checks, do not enter credentials or passcodes. Open private content only when the changed journey requires it. diff --git a/src/bootstrap/loadProFeatures.ts b/src/bootstrap/loadProFeatures.ts index 5f6b8bc58..c3ace1243 100644 --- a/src/bootstrap/loadProFeatures.ts +++ b/src/bootstrap/loadProFeatures.ts @@ -7,11 +7,31 @@ import { registerHook } from './hookRegistry'; import { getProLicenseInfo, registerProEntitlementProvider, + type ProLicenseInfo, } from '../services/proLicenseService'; import { proEntitlementLifecycle } from '../services/proEntitlementLifecycle'; -import { selectHasProAccess } from '../stores/proAccessSlice'; -export async function loadProFeatures(isPro?: boolean): Promise { +/** + * Bring the optional pro bundle (@offgrid/pro) into the app. + * + * Nothing here is gated. Off Grid AI is free: if the bundle is in this build, it activates - voice mode, + * personas, MCP tools, Personal Mesh sync, the lot - for everyone, with no licence, key, or purchase. + * Registration used to depend on a verified credential and the licensed-device roster's admission for + * this device, which meant a build could be fully capable and still refuse to switch on. It cannot any + * more. What follows a load is only a report of what was loaded, so the Settings surface can say whether + * this build carries the bundle at all. + * + * The bundle is optional, not conditional on payment: an open-core build (the default in this repo, where + * `pro/` is a submodule that is not checked out) resolves `@offgrid/pro` to the null stub, and this function + * returns false. Every Pro entry point in core handles that - it reads the registries, so an absent bundle + * shows a feature-less build rather than a route to a purchase, of which there are none left. + * + * The licence lifecycle below is deliberately kept: `revalidateProEntitlement` is how the provider keeps its + * cached credential and the licensed-device roster current, and mesh pairing between your own devices still + * needs a device identity. It no longer decides anything: an unreachable provider, an expired credential, or + * a device the roster calls inactive changes what the app displays, never what the app lets you do. + */ +export async function loadProFeatures(): Promise { let pro: any; try { logger.log('[BOOT-PRO] require(@offgrid/pro)'); @@ -21,7 +41,7 @@ export async function loadProFeatures(isPro?: boolean): Promise { } logger.log('[BOOT-PRO] require returned'); if (!pro) { - return false; // proStub.js returns null — free build via metro extraNodeModules + return false; // proStub.js returns null — build without the bundle } if (typeof pro.configureProEntitlementProvider === 'function') { pro.configureProEntitlementProvider(registerProEntitlementProvider); @@ -29,49 +49,34 @@ export async function loadProFeatures(isPro?: boolean): Promise { logger.log('[BOOT-PRO] proEntitlementLifecycle.start'); await proEntitlementLifecycle.start(); - // DEV ONLY: unlock pro features locally (audio mode, MCP) without a purchase so - // they can be tested on simulators/dev builds. __DEV__ is false in release - // builds, so this can never unlock pro in production. The Settings "Turn off - // Pro (DEV)" toggle sets devProDisabled to exercise the free → Pro flow in a - // debug build. - const { useAppStore } = require('../stores/appStore'); - const DEV_UNLOCK_PRO = __DEV__ && !useAppStore.getState().devProDisabled; - logger.log('[BOOT-PRO] getProLicenseInfo'); - const licenseInfo = await getProLicenseInfo(); - const credentialActive = isPro ?? licenseInfo.isPro; - const credentialSaved = - isPro === true || (licenseInfo.credentialSaved ?? licenseInfo.isPro); - const expired = licenseInfo.expired === true; - const active = (credentialActive || DEV_UNLOCK_PRO) && !expired; - // Single source of truth for "Pro is unlocked" — every upsell gate reads this, so a - // keychain- or dev-unlocked Pro user never sees the upgrade prompt. - useAppStore.getState().setHasRegisteredPro(credentialActive); - useAppStore.getState().setHasSavedProCredential(credentialSaved); - useAppStore.getState().setProActive(active); + const licenseInfo: ProLicenseInfo = await getProLicenseInfo(); + // A read-only projection of what this install holds, so Settings and the Pro features screen can be + // honest about it. Nothing branches on these values. Required lazily, as before, so the bootstrap + // keeps no static edge into the store. + const { useAppStore } = require('../stores/appStore'); + useAppStore.getState().setHasRegisteredPro(licenseInfo.isPro); useAppStore .getState() - .setHasExpiredProCredential(expired); - // A credential is not access. If the roster last told us this device is deactivated, the paid bundle - // must not load at all - loading it and then hiding the entry points leaves every Pro service running. - const admitted = - selectHasProAccess(useAppStore.getState()) || DEV_UNLOCK_PRO; - if (!expired && typeof pro.activateSyncBootstrap === 'function') { + .setHasSavedProCredential(licenseInfo.credentialSaved ?? licenseInfo.isPro); + useAppStore.getState().setProActive(licenseInfo.isPro); + useAppStore + .getState() + .setHasExpiredProCredential(licenseInfo.expired === true); + + // The restricted Sync bootstrap is registered before activation so a build whose bundle is missing can + // still participate in the mesh it may later be handed a role in. + if (typeof pro.activateSyncBootstrap === 'function') { pro.activateSyncBootstrap({ registerScreen, registerSlot, registerHook, onEntitlementImported: async () => { useAppStore.getState().setHasRegisteredPro(true); - await loadProFeatures(true); + await loadProFeatures(); }, }); } - if (!active || !admitted) { - // Sync stays reachable on purpose even here: claiming a seat again, by key or from a paired device, - // goes through the mesh runtime, so tearing it down would strand a deactivated device with no way back. - return false; // every other paid feature stays dormant - } logger.log('[BOOT-PRO] pro.activate'); pro.activate({ @@ -85,7 +90,7 @@ export async function loadProFeatures(isPro?: boolean): Promise { // Inject native OAuth adapters so MCP servers can use OAuth (browser sign-in + // Keychain token storage + PKCE crypto). Required before any OAuth connect; // until this runs the OAuth option stays hidden in the UI. Loaded lazily so - // free builds never pull in the native crypto/browser libs. + // builds without the bundle never pull in the native crypto/browser libs. if (typeof pro.configureOAuthAdapters === 'function') { try { const { diff --git a/src/bootstrap/slotRegistry.ts b/src/bootstrap/slotRegistry.ts index d78a39851..93a42d439 100644 --- a/src/bootstrap/slotRegistry.ts +++ b/src/bootstrap/slotRegistry.ts @@ -1,20 +1,20 @@ import { useSyncExternalStore, type ComponentType } from 'react'; /** - * Component-slot seam. Pro features register UI components against named slots - * during activation; core renders whatever is registered and renders nothing - * (its own fallback) when a slot is empty. This lets the private pro package - * inject UI into core screens without core ever importing pro — free builds - * leave every slot empty and behave exactly as before. + * Component-slot seam. The optional pro bundle registers UI components against named slots + * during activation; core renders whatever is registered and renders nothing (its own + * fallback) when a slot is empty. This lets the pro package inject UI into core screens + * without core ever importing pro — a build compiled without the bundle leaves every slot + * empty and behaves exactly as before. * * Slot names are plain strings (see SLOTS below for the known set). Props are * passed through untyped on purpose: each slot documents its own prop shape and * the registering component declares it. * * Registration is reactive: `useSlot` subscribes, so a slot registered AFTER a - * consumer has mounted (e.g. Pro activated at runtime via a license key, which - * re-runs loadProFeatures) makes that consumer re-render and pick the component - * up live — no app restart. `getSlot` stays for non-component / one-shot reads. + * consumer has mounted (e.g. the pro bundle loaded at runtime, which re-runs + * loadProFeatures) makes that consumer re-render and pick the component up live — no app + * restart. `getSlot` stays for non-component / one-shot reads. */ const slots: Record> = {}; const listeners = new Set<() => void>(); diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx index 8c8f5f1a8..9bc1ca091 100644 --- a/src/components/ChatInput/index.tsx +++ b/src/components/ChatInput/index.tsx @@ -23,7 +23,7 @@ import { getSlot, SLOTS } from '../../bootstrap/slotRegistry'; import { AppSheet } from '../AppSheet'; import { Button } from '../Button'; -type AssistantAvailability = 'no-pro' | 'needs-sync' | 'unavailable' | 'ready'; +type AssistantAvailability = 'no-bundle' | 'needs-sync' | 'unavailable' | 'ready'; interface ChatInputProps { onSend: (message: string, attachments?: MediaAttachment[], imageMode?: ImageModeState, assistantEnabled?: boolean) => void; @@ -57,7 +57,6 @@ interface ChatInputProps { * attachment thumbnail is tapped — the same handler in-message images use. */ onImagePress?: (uri: string) => void; assistantAvailability?: AssistantAvailability; - onAssistantUpgrade?: () => void; onAssistantSetupSync?: () => void; onAssistantEnableTools?: () => boolean; assistantSelected?: boolean; @@ -159,8 +158,7 @@ export const ChatInput: React.FC = ({ isRemote = false, showSettingsDot = false, onImagePress, - assistantAvailability = 'no-pro', - onAssistantUpgrade, + assistantAvailability = 'no-bundle', onAssistantSetupSync, onAssistantEnableTools, assistantSelected, @@ -341,12 +339,14 @@ export const ChatInput: React.FC = ({ }, ANIM_DURATION_IN); }; - const assistantGate = assistantAvailability === 'no-pro' + const assistantGate = assistantAvailability === 'no-bundle' ? { - title: 'Assistant requires Pro', - body: 'Assistant uses Web Use and Computer Use on a connected Desktop.', - action: 'View Pro', - onPress: onAssistantUpgrade, + // A build without the optional pro bundle has no Desktop task tools to offer. This says so, and + // stops there: there is nothing to buy, so the gate carries no call to action. + title: 'Desktop tasks are not in this build', + body: 'Assistant uses Web Use and Computer Use on a connected Desktop, and ships in the optional Pro bundle. This build does not include it.', + action: 'OK', + onPress: undefined, } : assistantAvailability === 'unavailable' ? { diff --git a/src/components/ProAhaSheet.tsx b/src/components/ProAhaSheet.tsx deleted file mode 100644 index d63a0fbe7..000000000 --- a/src/components/ProAhaSheet.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React from 'react'; -import { View, Text, TouchableOpacity } from 'react-native'; -import Icon from 'react-native-vector-icons/Feather'; -import { AppSheet } from './AppSheet'; -import { useThemedStyles } from '../theme'; -import type { ThemeColors, ThemeShadows } from '../theme'; -import { SPACING, TYPOGRAPHY, PRO_AHA_FEATURES } from '../constants'; -import { getPricingCopy } from '../utils/proPricing'; - -interface ProAhaSheetProps { - visible: boolean; - onClose: () => void; - onRegister: () => void; -} - -export const ProAhaSheet: React.FC = ({ visible, onClose, onRegister }) => { - const styles = useThemedStyles(createStyles); - const pricing = getPricingCopy(); - - const handleCta = () => { - onClose(); - onRegister(); - }; - - return ( - - - It already knows what you did today. - {pricing.sheetSubheadline} - - - {PRO_AHA_FEATURES.map(feature => ( - - - {feature} - - ))} - - - {pricing.sheetFooter} - - - {pricing.cta} - - - - ); -}; - -const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ - content: { - paddingHorizontal: SPACING.xl, - paddingTop: SPACING.md, - paddingBottom: SPACING.xxl, - alignItems: 'center' as const, - }, - headline: { - ...TYPOGRAPHY.h2, - color: colors.text, - textAlign: 'center' as const, - marginBottom: SPACING.sm, - }, - subheadline: { - ...TYPOGRAPHY.body, - color: colors.textSecondary, - textAlign: 'center' as const, - marginBottom: SPACING.md, - }, - priceRow: { - marginBottom: SPACING.lg, - }, - price: { - ...TYPOGRAPHY.bodySmall, - color: colors.primary, - textAlign: 'center' as const, - }, - featureList: { - width: '100%' as const, - marginBottom: SPACING.lg, - gap: SPACING.sm, - }, - featureRow: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - gap: SPACING.sm, - }, - checkIcon: { - color: colors.primary, - }, - featureText: { - ...TYPOGRAPHY.body, - color: colors.text, - }, - guarantee: { - ...TYPOGRAPHY.bodySmall, - color: colors.textMuted, - textAlign: 'center' as const, - marginBottom: SPACING.lg, - }, - ctaButton: { - width: '100%' as const, - paddingVertical: SPACING.md, - backgroundColor: colors.primary, - borderRadius: 8, - alignItems: 'center' as const, - justifyContent: 'center' as const, - marginBottom: SPACING.sm, - }, - ctaText: { - ...TYPOGRAPHY.body, - color: colors.background, - }, -}); diff --git a/src/components/index.ts b/src/components/index.ts index b483e0f5a..5ae9df9b6 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -31,5 +31,4 @@ export { VisionRepairAdviceCard } from './VisionRepairAdviceCard'; export { ProjectSelectorSheet } from './ProjectSelectorSheet'; export { DebugSheet } from './DebugSheet'; export { SharePromptSheet } from './SharePromptSheet'; -export { ProAhaSheet } from './ProAhaSheet'; ; diff --git a/src/components/models/VoiceModelsEmptyState.tsx b/src/components/models/VoiceModelsEmptyState.tsx new file mode 100644 index 000000000..3ae9e7488 --- /dev/null +++ b/src/components/models/VoiceModelsEmptyState.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, Linking } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { useTheme, useThemedStyles } from '../../theme'; +import type { ThemeColors } from '../../theme'; +import { + TYPOGRAPHY, + SPACING, + OFF_GRID_DESKTOP_BENEFIT, + OFF_GRID_DESKTOP_URL, +} from '../../constants'; +import { withUtm } from '../../utils/utm'; + +/** + * Shown in the Voice tab when no voice engine is registered — i.e. in a build without the optional pro + * bundle, which is where on-device text-to-speech lives. + * + * This used to be an upsell: a pitch, a "Get Pro" button and a route to the pay page. Voice is free now, + * so there is nothing to sell and no price to state. What is left is the honest version of the empty + * state — this build has no voice engine, here is what would appear if it did — plus the free Desktop + * companion, which can do the talking when your phone cannot. + */ +export const VoiceModelsEmptyState: React.FC = () => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + + return ( + + + + + Voice models + + On-device text-to-speech ships in the optional Pro bundle, and this + build does not include it. There is nothing to buy: a build with the + bundle offers every voice to everyone. When it is here, download a voice + and the assistant can speak its replies, generated in your phone's RAM + and never sent anywhere. + + + Linking.openURL(withUtm(OFF_GRID_DESKTOP_URL, 'voice-models')).catch( + () => {}, + ) + } + accessibilityRole="link" + accessibilityLabel="Get Off Grid AI Desktop" + > + + {OFF_GRID_DESKTOP_BENEFIT} + + + ); +}; + +const createStyles = (colors: ThemeColors) => ({ + container: { + flex: 1, + alignItems: 'center' as const, + justifyContent: 'center' as const, + paddingHorizontal: SPACING.xl, + }, + iconCircle: { + width: 64, + height: 64, + borderRadius: 32, + alignItems: 'center' as const, + justifyContent: 'center' as const, + backgroundColor: colors.surfaceLight, + marginBottom: SPACING.lg, + }, + title: { ...TYPOGRAPHY.h3, color: colors.text, marginBottom: SPACING.sm }, + body: { + ...TYPOGRAPHY.body, + color: colors.textSecondary, + textAlign: 'center' as const, + lineHeight: 22, + marginBottom: SPACING.xl, + }, + desktopLink: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: SPACING.xs, + marginTop: SPACING.sm, + paddingVertical: SPACING.xs, + }, + desktopLinkText: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted }, +}); diff --git a/src/components/models/VoiceModelsSheet.tsx b/src/components/models/VoiceModelsSheet.tsx index 8ee744db1..3c197b86d 100644 --- a/src/components/models/VoiceModelsSheet.tsx +++ b/src/components/models/VoiceModelsSheet.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { View, Dimensions } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; import { AppSheet } from '../../components/AppSheet'; -import { VoiceModelsUpsell } from './VoiceModelsUpsell'; +import { VoiceModelsEmptyState } from './VoiceModelsEmptyState'; import { useSlot, SLOTS } from '../../bootstrap/slotRegistry'; import { useThemedStyles } from '../../theme'; import type { ThemeColors } from '../../theme'; @@ -19,9 +18,10 @@ type Props = { const PANEL_HEIGHT = Math.round(Dimensions.get('window').height * 0.6); /** - * Voice (TTS) model picker — reuses the pro Voice panel (engine selection + - * link to voice options) rendered via the modelsScreen.voiceTab slot. Renders an - * empty-state line in free builds where the slot isn't registered. + * Voice (TTS) model picker — reuses the voice panel (engine selection + link to voice options) that the + * optional pro bundle registers via the modelsScreen.voiceTab slot. In a build without the bundle the + * slot is empty and the empty state explains that, with no route to a purchase: voice is free, and the + * only thing a build can lack is the code. */ export const VoiceModelsSheet: React.FC = ({ visible, @@ -30,19 +30,10 @@ export const VoiceModelsSheet: React.FC = ({ onBackToModels, }) => { const styles = useThemedStyles(createStyles); - const navigation = useNavigation(); - // Reactive: picks up the pro Voice panel live when Pro is activated at runtime + // Reactive: picks up the pro Voice panel live when the bundle is loaded at runtime // (loadProFeatures registers the slot), without needing an app restart. const VoicePanel = useSlot(SLOTS.modelsScreenVoiceTab); - // Free build: show the same Pro upsell the Models → Voice tab uses, instead of - // a dead-end "not available" line. Close the sheet first so the Pro screen - // isn't presented while this bottom sheet is still dismissing (iOS). - const handleGetPro = () => { - onClose(); - (navigation as unknown as { navigate: (name: string) => void }).navigate('ProDetail'); - }; - return ( = ({ enableDynamicSizing > - {VoicePanel ? : } + {VoicePanel ? : } ); diff --git a/src/components/models/VoiceModelsUpsell.tsx b/src/components/models/VoiceModelsUpsell.tsx deleted file mode 100644 index 3d90a0ef5..000000000 --- a/src/components/models/VoiceModelsUpsell.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React from 'react'; -import { View, Text, TouchableOpacity, Linking } from 'react-native'; -import Icon from 'react-native-vector-icons/Feather'; -import { Button } from '../Button'; -import { useTheme, useThemedStyles } from '../../theme'; -import type { ThemeColors } from '../../theme'; -import { - TYPOGRAPHY, - SPACING, - OFF_GRID_DESKTOP_BENEFIT, - OFF_GRID_DESKTOP_URL, -} from '../../constants'; -import { withUtm } from '../../utils/utm'; - -interface VoiceModelsUpsellProps { - /** Navigates to the Pro detail screen. */ - onGetPro: () => void; -} - -/** - * Shown in the Voice tab when no voice engine is registered (free / non-pro - * builds). The pro feature fills the modelsScreen.voiceTab slot; when it's - * absent the tab still renders so users can see what Pro adds. - */ -export const VoiceModelsUpsell: React.FC = ({ onGetPro }) => { - const { colors } = useTheme(); - const styles = useThemedStyles(createStyles); - - return ( - - - - - Voice models - - Off Grid AI Pro adds on-device text-to-speech. The voice model runs in your - phone's RAM, so what you hear is generated on the device and never sent - anywhere. Download a voice and the assistant can speak its replies. - -