From 885e70e7d70ccfe3df8fa69026a5a172c160281b Mon Sep 17 00:00:00 2001 From: allennakalema06-web Date: Tue, 6 Oct 2026 00:22:53 +0300 Subject: [PATCH 1/2] feat(formulus): support settings deep links --- formulus/App.tsx | 17 +++++- .../android/app/src/main/AndroidManifest.xml | 10 ++++ formulus/ios/Formulus/Info.plist | 11 ++++ formulus/scripts/generateQR.ts | 3 +- .../src/navigation/ProfileNavigationTypes.ts | 6 +- formulus/src/screens/ProfilesScreen.tsx | 38 +++++++++++- formulus/src/services/QRSettingsService.ts | 19 +++++- .../__tests__/QRSettingsService.test.ts | 59 +++++++++++++++++++ synkronus-cli/internal/cmd/qr.go | 3 +- synkronus-portal/src/utils/frmls.ts | 3 +- 10 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 formulus/src/services/__tests__/QRSettingsService.test.ts diff --git a/formulus/App.tsx b/formulus/App.tsx index a7b4cff2b..3e89523ce 100644 --- a/formulus/App.tsx +++ b/formulus/App.tsx @@ -29,6 +29,21 @@ import { FormInitData } from './src/webview/FormulusInterfaceDefinition.ts'; import { FormSpec } from './src/services'; import { initFormulusI18n, i18n } from './src/i18n'; import { DirtyExitGate } from './src/diagnostics/DirtyExitGate'; +import type { LinkingOptions } from '@react-navigation/native'; +import type { MainAppStackParamList } from './src/navigation/ProfileNavigationTypes'; + +const linking: LinkingOptions = { + prefixes: ['formulus://'], + config: { + screens: { + MainApp: { + screens: { + Profiles: 'settings', + }, + }, + }, + }, +}; /** * Inner component that consumes the AppTheme context to build a dynamic @@ -276,7 +291,7 @@ function AppInner(): React.JSX.Element { backgroundColor={themeColors.surface} /> - + {formplayerStack.map((entry, index) => ( + + + + + + + + $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleURLTypes + + + CFBundleURLName + org.opendataensemble.formulus + CFBundleURLSchemes + + formulus + + + CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/formulus/scripts/generateQR.ts b/formulus/scripts/generateQR.ts index 39b54c5ae..fcb8691d9 100644 --- a/formulus/scripts/generateQR.ts +++ b/formulus/scripts/generateQR.ts @@ -59,7 +59,8 @@ function main() { try { const frmls = parseArgs(); const encoded = encodeFRMLS(frmls); - console.log(encoded); + const deepLink = `formulus://settings?payload=${encodeURIComponent(encoded)}`; + console.log(deepLink); } catch (error) { console.error( 'Error:', diff --git a/formulus/src/navigation/ProfileNavigationTypes.ts b/formulus/src/navigation/ProfileNavigationTypes.ts index 7f49d157f..c00f3f494 100644 --- a/formulus/src/navigation/ProfileNavigationTypes.ts +++ b/formulus/src/navigation/ProfileNavigationTypes.ts @@ -6,7 +6,11 @@ import type { // Keep the profile UI's route extension alongside its navigator. export type MainTabParamList = BaseMainTabParamList & { - Profiles: undefined; + Profiles: + | { + payload?: string; + } + | undefined; }; export type MainAppStackParamList = Omit< diff --git a/formulus/src/screens/ProfilesScreen.tsx b/formulus/src/screens/ProfilesScreen.tsx index b4b6759ee..07c2d8397 100644 --- a/formulus/src/screens/ProfilesScreen.tsx +++ b/formulus/src/screens/ProfilesScreen.tsx @@ -1,4 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs'; +import type { MainTabParamList } from '../navigation/ProfileNavigationTypes'; import { ScrollView, StyleSheet, @@ -21,7 +23,10 @@ import { profileRegistry } from '../profiles/ProfileRegistry'; import { switchProfile, deleteProfile } from '../profiles/ProfileTransitions'; import { ToastService } from '../services/ToastService'; import { setCredentialsForProfile } from '../profiles/ProfileKeychain'; -import type { SettingsUpdate } from '../services/QRSettingsService'; +import { + QRSettingsService, + type SettingsUpdate, +} from '../services/QRSettingsService'; import { odeSpacing, odeTypography, @@ -41,7 +46,8 @@ type LabelEditor = { initialSettings?: SettingsUpdate; }; -const ProfilesScreen = () => { +type ProfilesScreenProps = BottomTabScreenProps; +const ProfilesScreen = ({ route, navigation }: ProfilesScreenProps) => { const { t } = useTranslation(); const { themeColors } = useAppTheme(); const shellStyle = useScreenShellStyle(); @@ -61,6 +67,34 @@ const ProfilesScreen = () => { }; }, []); + useEffect(() => { + const payload = route.params?.payload; + if (!payload) return; + + const processDeepLink = async () => { + try { + const settings = await QRSettingsService.processQRCode( + `formulus://settings?payload=${encodeURIComponent(payload)}`, + ); + + if (!mountedRef.current) return; + + setDropdownOpen(false); + setEditor({ label: '', initialSettings: settings }); + } catch { + if (mountedRef.current) { + ToastService.showLong(t('settings.qrInvalid')); + } + } finally { + if (mountedRef.current) { + navigation.setParams({ payload: undefined }); + } + } + }; + + void processDeepLink(); + }, [navigation, route.params?.payload, t]); + const runAction = useCallback( async (operation, fallbackKey) => { if (busyRef.current || !mountedRef.current) return; diff --git a/formulus/src/services/QRSettingsService.ts b/formulus/src/services/QRSettingsService.ts index 58a9535a8..33f3607e8 100644 --- a/formulus/src/services/QRSettingsService.ts +++ b/formulus/src/services/QRSettingsService.ts @@ -1,6 +1,23 @@ import { decodeFRMLS } from '../utils/FRMLSHelpers'; import { normalizeServerUrl } from './normalizeServerUrl'; +const FORMULUS_SETTINGS_URL = 'formulus://settings'; + +function unwrapQRCode(qrString: string): string { + if (qrString.startsWith('FRMLS:')) return qrString; + + const prefix = `${FORMULUS_SETTINGS_URL}?payload=`; + if (!qrString.startsWith(prefix)) { + throw new Error('Not a Formulus settings link'); + } + + const encodedPayload = qrString.slice(prefix.length); + if (!encodedPayload) { + throw new Error('Missing FRMLS payload'); + } + + return decodeURIComponent(encodedPayload); +} export interface SettingsUpdate { serverUrl: string; username: string; @@ -13,7 +30,7 @@ export class QRSettingsService { */ static parseQRCode(qrString: string): SettingsUpdate { try { - const frmls = decodeFRMLS(qrString); + const frmls = decodeFRMLS(unwrapQRCode(qrString)); return { serverUrl: frmls.s, diff --git a/formulus/src/services/__tests__/QRSettingsService.test.ts b/formulus/src/services/__tests__/QRSettingsService.test.ts new file mode 100644 index 000000000..d4ca1b599 --- /dev/null +++ b/formulus/src/services/__tests__/QRSettingsService.test.ts @@ -0,0 +1,59 @@ +/// + +import { describe, it, expect } from '@jest/globals'; +import { encodeFRMLS } from '../../utils/FRMLSHelpers'; +import { QRSettingsService } from '../QRSettingsService'; + +describe('QRSettingsService', () => { + it('parses a bare FRMLS payload', () => { + const qr = encodeFRMLS({ + v: 1, + s: 'https://example.org/', + u: 'allen', + p: 'secret', + }); + + const settings = QRSettingsService.parseQRCode(qr); + + expect(settings).toEqual({ + serverUrl: 'https://example.org/', + username: 'allen', + password: 'secret', + }); + }); + it('parses an FRMLS payload wrapped in a Formulus settings deep link', () => { + const frmls = encodeFRMLS({ + v: 1, + s: 'https://example.org/', + u: 'allen', + p: 'secret', + }); + const qr = `formulus://settings?payload=${encodeURIComponent(frmls)}`; + + const settings = QRSettingsService.parseQRCode(qr); + + expect(settings).toEqual({ + serverUrl: 'https://example.org/', + username: 'allen', + password: 'secret', + }); + }); + it('rejects a non-Formulus deep link', () => { + const frmls = encodeFRMLS({ + v: 1, + s: 'https://example.org/', + u: 'allen', + p: 'secret', + }); + const qr = `https://example.org/settings?payload=${encodeURIComponent(frmls)}`; + + expect(() => QRSettingsService.parseQRCode(qr)).toThrow( + 'Not a Formulus settings link', + ); + }); + it('rejects a Formulus settings deep link with no payload', () => { + expect(() => + QRSettingsService.parseQRCode('formulus://settings?payload='), + ).toThrow('Missing FRMLS payload'); + }); +}); diff --git a/synkronus-cli/internal/cmd/qr.go b/synkronus-cli/internal/cmd/qr.go index 9437ac8f9..a36e5bd81 100644 --- a/synkronus-cli/internal/cmd/qr.go +++ b/synkronus-cli/internal/cmd/qr.go @@ -143,6 +143,7 @@ for the Formulus mobile app. The QR encodes server URL, username, and password.` } encoded := encodeFRMLS(1, serverURL, username, password) + deepLink := "formulus://settings?payload=" + url.QueryEscape(encoded) outputFile, err := cmd.Flags().GetString("output") if err != nil { @@ -154,7 +155,7 @@ for the Formulus mobile app. The QR encodes server URL, username, and password.` // Generate QR code with logo using yeqown/go-qrcode logoPath := "qr_logo.png" - qrc, err := qrcode.New(encoded) + qrc, err := qrcode.New(deepLink) if err != nil { return fmt.Errorf("failed to create QR code: %w", err) } diff --git a/synkronus-portal/src/utils/frmls.ts b/synkronus-portal/src/utils/frmls.ts index c737c38fd..2e61026fb 100644 --- a/synkronus-portal/src/utils/frmls.ts +++ b/synkronus-portal/src/utils/frmls.ts @@ -32,10 +32,11 @@ export function encodeFormulusLoginQr( username: string, password: string, ): string { - return encodeFRMLS({ + const frmls = encodeFRMLS({ v: 1, s: serverUrl, u: username, p: password, }); + return `formulus://settings?payload=${encodeURIComponent(frmls)}`; } From 41b6ed47d58dab0daa2131c0bd05e5edb0aaa6d0 Mon Sep 17 00:00:00 2001 From: allennakalema06-web Date: Tue, 6 Oct 2026 07:42:49 +0300 Subject: [PATCH 2/2] fix(formulus): handle missing profile route params --- formulus/src/screens/ProfilesScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/formulus/src/screens/ProfilesScreen.tsx b/formulus/src/screens/ProfilesScreen.tsx index 07c2d8397..1afeae26b 100644 --- a/formulus/src/screens/ProfilesScreen.tsx +++ b/formulus/src/screens/ProfilesScreen.tsx @@ -68,7 +68,7 @@ const ProfilesScreen = ({ route, navigation }: ProfilesScreenProps) => { }, []); useEffect(() => { - const payload = route.params?.payload; + const payload = route?.params?.payload; if (!payload) return; const processDeepLink = async () => { @@ -93,7 +93,7 @@ const ProfilesScreen = ({ route, navigation }: ProfilesScreenProps) => { }; void processDeepLink(); - }, [navigation, route.params?.payload, t]); + }, [navigation, route?.params?.payload, t]); const runAction = useCallback( async (operation, fallbackKey) => {