From 885e70e7d70ccfe3df8fa69026a5a172c160281b Mon Sep 17 00:00:00 2001 From: allennakalema06-web Date: Tue, 6 Oct 2026 00:22:53 +0300 Subject: [PATCH 1/4] 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/4] 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) => { From 37e9a025ba29e4391fae2db976a70cf06a716fc1 Mon Sep 17 00:00:00 2001 From: allennakalema06-web Date: Thu, 15 Oct 2026 23:02:49 +0300 Subject: [PATCH 3/4] fix(formulus): address settings deep-link review feedback --- formulus/ios/Formulus/Info.plist | 11 -- formulus/src/locales/en.json | 1 + formulus/src/screens/ProfilesScreen.tsx | 20 +++- .../screens/__tests__/ProfilesScreen.test.tsx | 105 +++++++++++++++++- formulus/src/services/QRSettingsService.ts | 82 ++++++++++++-- .../__tests__/QRSettingsService.test.ts | 77 ++++++++++++- 6 files changed, 266 insertions(+), 30 deletions(-) diff --git a/formulus/ios/Formulus/Info.plist b/formulus/ios/Formulus/Info.plist index 7f8ca1521..a39b6628c 100644 --- a/formulus/ios/Formulus/Info.plist +++ b/formulus/ios/Formulus/Info.plist @@ -10,17 +10,6 @@ $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleURLTypes - - - CFBundleURLName - org.opendataensemble.formulus - CFBundleURLSchemes - - formulus - - - CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/formulus/src/locales/en.json b/formulus/src/locales/en.json index d12013c7c..905eeb74d 100644 --- a/formulus/src/locales/en.json +++ b/formulus/src/locales/en.json @@ -20,6 +20,7 @@ "profiles.switchTitle": "Switch profile?", "profiles.switchMessage": "Switch to “{{label}}”? Finish open forms and wait for sync before switching.", "profiles.scanQr": "Scan QR", + "profiles.finishCreationFirst": "Finish or cancel the current profile before opening another QR link.", "profiles.notConfigured": "No server configured", "profiles.add": "Add profile", "profiles.rename": "Rename", diff --git a/formulus/src/screens/ProfilesScreen.tsx b/formulus/src/screens/ProfilesScreen.tsx index 1afeae26b..be3f257f3 100644 --- a/formulus/src/screens/ProfilesScreen.tsx +++ b/formulus/src/screens/ProfilesScreen.tsx @@ -55,6 +55,11 @@ const ProfilesScreen = ({ route, navigation }: ProfilesScreenProps) => { const { profiles, activeProfile } = useProfiles(); const [editor, setEditor] = useState(null); + const editorRef = useRef(editor); + + useEffect(() => { + editorRef.current = editor; + }, [editor]); const [dropdownOpen, setDropdownOpen] = useState(false); const [busy, setBusy] = useState(false); const busyRef = useRef(false); @@ -70,14 +75,21 @@ const ProfilesScreen = ({ route, navigation }: ProfilesScreenProps) => { useEffect(() => { const payload = route?.params?.payload; if (!payload) return; + if (editor) { + ToastService.showLong(t('profiles.finishCreationFirst')); + navigation.setParams({ payload: undefined }); + return; + } const processDeepLink = async () => { try { - const settings = await QRSettingsService.processQRCode( - `formulus://settings?payload=${encodeURIComponent(payload)}`, - ); + const settings = await QRSettingsService.processPayload(payload); if (!mountedRef.current) return; + if (editorRef.current) { + ToastService.showLong(t('profiles.finishCreationFirst')); + return; + } setDropdownOpen(false); setEditor({ label: '', initialSettings: settings }); @@ -93,7 +105,7 @@ const ProfilesScreen = ({ route, navigation }: ProfilesScreenProps) => { }; void processDeepLink(); - }, [navigation, route?.params?.payload, t]); + }, [editor, navigation, route?.params?.payload, t]); const runAction = useCallback( async (operation, fallbackKey) => { diff --git a/formulus/src/screens/__tests__/ProfilesScreen.test.tsx b/formulus/src/screens/__tests__/ProfilesScreen.test.tsx index 52fe39363..dd30501d5 100644 --- a/formulus/src/screens/__tests__/ProfilesScreen.test.tsx +++ b/formulus/src/screens/__tests__/ProfilesScreen.test.tsx @@ -48,7 +48,13 @@ jest.mock('react-native', () => { const mockNavigate = jest.fn(); const mockReset = jest.fn(); -const mockNavigation = { navigate: mockNavigate, reset: mockReset }; +const mockSetParams = jest.fn(); + +const mockNavigation = { + navigate: mockNavigate, + reset: mockReset, + setParams: mockSetParams, +}; const mockConfirm = jest.fn(); const mockListeners = new Set<() => void>(); let mockQRResult: (result: ScannerModalResults) => void; @@ -223,7 +229,10 @@ jest.mock('../../services/ServerConfigService', () => ({ serverConfigService: { isHealthEndpointOk: jest.fn() }, })); jest.mock('../../services/QRSettingsService', () => ({ - QRSettingsService: { processQRCode: jest.fn() }, + QRSettingsService: { + processQRCode: jest.fn(), + processPayload: jest.fn(), + }, })); jest.mock('../../services/SettingsHydrationCache', () => ({ loadSettingsHydrationFromStorage: jest.fn(), @@ -345,6 +354,11 @@ beforeEach(() => { username: 'qr-user', password: 'qr-secret', }); + jest.mocked(QRSettingsService.processPayload).mockResolvedValue({ + serverUrl: 'https://scanned.example', + username: 'scan-user', + password: 'scan-secret', + }); }); async function setup() { @@ -790,6 +804,93 @@ test('new-profile panel requires a name and Cancel abandons manual entry', async expect(profileRegistry.add).not.toHaveBeenCalled(); }); +test('incoming deep link prefills a new profile without saving', async () => { + const route = { + params: { payload: 'FRMLS:test-payload' }, + }; + + const screen = render( + , + ); + + await waitFor(() => { + expect(QRSettingsService.processPayload).toHaveBeenCalledWith( + 'FRMLS:test-payload', + ); + }); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Server URL')).toHaveProp( + 'value', + 'https://scanned.example', + ); + }); + + expect(screen.getByPlaceholderText('Username')).toHaveProp( + 'value', + 'scan-user', + ); + + expect(screen.getByPlaceholderText('Profile name')).toHaveProp('value', ''); + + expect(profileRegistry.add).not.toHaveBeenCalled(); + expect(mockSetParams).toHaveBeenCalledWith({ + payload: undefined, + }); +}); +test('incoming deep link does not overwrite an unfinished profile', async () => { + const route = { params: {} as { payload?: string } }; + + const screen = render( + , + ); + + await waitFor(() => + expect(screen.getByPlaceholderText('Username')).toHaveProp( + 'editable', + true, + ), + ); + + fireEvent.press(screen.getByRole('button', { name: 'Add profile' })); + + fireEvent.changeText( + screen.getByPlaceholderText('Profile name'), + 'My unfinished profile', + ); + + fireEvent.changeText( + screen.getByPlaceholderText('Server URL'), + 'https://original.example', + ); + + route.params = { payload: 'FRMLS:test-payload' }; + + screen.rerender( + , + ); + + await waitFor(() => + expect(ToastService.showLong).toHaveBeenCalledWith( + 'Finish or cancel the current profile before opening another QR link.', + ), + ); + + expect(screen.getByPlaceholderText('Profile name')).toHaveProp( + 'value', + 'My unfinished profile', + ); + + expect(screen.getByPlaceholderText('Server URL')).toHaveProp( + 'value', + 'https://original.example', + ); + + expect(QRSettingsService.processPayload).not.toHaveBeenCalled(); + expect(profileRegistry.add).not.toHaveBeenCalled(); + expect(mockSetParams).toHaveBeenCalledWith({ payload: undefined }); +}); + test('creation QR fills manual fields without saving until a name is entered', async () => { jest.mocked(QRSettingsService.processQRCode).mockResolvedValueOnce({ serverUrl: 'https://scanned.example', diff --git a/formulus/src/services/QRSettingsService.ts b/formulus/src/services/QRSettingsService.ts index 33f3607e8..e679b8587 100644 --- a/formulus/src/services/QRSettingsService.ts +++ b/formulus/src/services/QRSettingsService.ts @@ -1,23 +1,50 @@ 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; + // Preserve legacy FRMLS QR codes. + if (qrString.startsWith('FRMLS:')) { + return qrString; + } + + // Accept formulus://settings and formulus://settings/ + // with a query string. + const match = /^formulus:\/\/settings\/?\?([^#]*)$/i.exec(qrString); - const prefix = `${FORMULUS_SETTINGS_URL}?payload=`; - if (!qrString.startsWith(prefix)) { + if (!match) { throw new Error('Not a Formulus settings link'); } - const encodedPayload = qrString.slice(prefix.length); - if (!encodedPayload) { - throw new Error('Missing FRMLS payload'); + const query = match[1]; + const payloads: string[] = []; + + for (const parameter of query.split('&')) { + const separator = parameter.indexOf('='); + const rawKey = separator === -1 ? parameter : parameter.slice(0, separator); + const rawValue = separator === -1 ? '' : parameter.slice(separator + 1); + + let key: string; + let value: string; + + try { + key = decodeURIComponent(rawKey.replace(/\+/g, ' ')); + value = decodeURIComponent(rawValue.replace(/\+/g, ' ')); + } catch { + throw new Error('Malformed settings link encoding'); + } + + if (key === 'payload') { + payloads.push(value); + } + } + + if (payloads.length !== 1 || !payloads[0]) { + throw new Error('Missing or duplicate FRMLS payload'); } - return decodeURIComponent(encodedPayload); + return payloads[0]; } + export interface SettingsUpdate { serverUrl: string; username: string; @@ -26,11 +53,11 @@ export interface SettingsUpdate { export class QRSettingsService { /** - * Parses a QR code string and extracts settings + * Parses an already extracted FRMLS payload. */ - static parseQRCode(qrString: string): SettingsUpdate { + static parsePayload(payload: string): SettingsUpdate { try { - const frmls = decodeFRMLS(unwrapQRCode(qrString)); + const frmls = decodeFRMLS(payload); return { serverUrl: frmls.s, @@ -46,6 +73,35 @@ export class QRSettingsService { } } + /** + * Parses a complete QR code string and extracts settings. + */ + static parseQRCode(qrString: string): SettingsUpdate { + try { + return this.parsePayload(unwrapQRCode(qrString)); + } catch (error) { + throw new Error( + `Invalid QR code format: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ); + } + } + + /** + * Parse and normalize an already extracted FRMLS payload. + */ + static async processPayload(payload: string): Promise { + const settings = this.parsePayload(payload); + const normalized = normalizeServerUrl(settings.serverUrl); + + if (!normalized.ok) { + throw new Error(normalized.message); + } + + return { ...settings, serverUrl: normalized.href }; + } + /** * Parse and normalize QR settings without persisting credentials. The caller * saves the server choice and login() stores credentials only after auth succeeds. @@ -53,9 +109,11 @@ export class QRSettingsService { static async processQRCode(qrString: string): Promise { const settings = this.parseQRCode(qrString); const normalized = normalizeServerUrl(settings.serverUrl); + if (!normalized.ok) { throw new Error(normalized.message); } + return { ...settings, serverUrl: normalized.href }; } } diff --git a/formulus/src/services/__tests__/QRSettingsService.test.ts b/formulus/src/services/__tests__/QRSettingsService.test.ts index d4ca1b599..3ff34004e 100644 --- a/formulus/src/services/__tests__/QRSettingsService.test.ts +++ b/formulus/src/services/__tests__/QRSettingsService.test.ts @@ -54,6 +54,81 @@ describe('QRSettingsService', () => { it('rejects a Formulus settings deep link with no payload', () => { expect(() => QRSettingsService.parseQRCode('formulus://settings?payload='), - ).toThrow('Missing FRMLS payload'); + ).toThrow('Missing or duplicate FRMLS payload'); + }); + + it('parses a settings link with a trailing slash', () => { + const frmls = encodeFRMLS({ + v: 1, + s: 'https://example.org/', + u: 'allen', + p: 'secret', + }); + + const qr = `formulus://settings/?payload=${encodeURIComponent(frmls)}`; + + expect(QRSettingsService.parseQRCode(qr)).toEqual({ + serverUrl: 'https://example.org/', + username: 'allen', + password: 'secret', + }); +}); + +it('parses a settings link with additional query parameters', () => { + const frmls = encodeFRMLS({ + v: 1, + s: 'https://example.org/', + u: 'allen', + p: 'secret', + }); + + const qr = + `formulus://settings?source=qr&payload=${encodeURIComponent(frmls)}` + + '&version=1'; + + expect(QRSettingsService.parseQRCode(qr)).toEqual({ + serverUrl: 'https://example.org/', + username: 'allen', + password: 'secret', + }); +}); + +it('rejects duplicate payload parameters', () => { + expect(() => + QRSettingsService.parseQRCode( + 'formulus://settings?payload=first&payload=second', + ), + ).toThrow('Missing or duplicate FRMLS payload'); +}); + +it('rejects malformed percent-encoding', () => { + expect(() => + QRSettingsService.parseQRCode( + 'formulus://settings?payload=%ZZ', + ), + ).toThrow('Malformed settings link encoding'); +}); + +it('rejects a settings link with an unexpected path', () => { + expect(() => + QRSettingsService.parseQRCode( + 'formulus://settings/other?payload=test', + ), + ).toThrow('Not a Formulus settings link'); +}); + +it('parses an extracted payload directly', () => { + const frmls = encodeFRMLS({ + v: 1, + s: 'https://example.org/', + u: 'allen', + p: 'secret', + }); + + expect(QRSettingsService.parsePayload(frmls)).toEqual({ + serverUrl: 'https://example.org/', + username: 'allen', + password: 'secret', + }); }); }); From 05459db1ad56f0bf971b2196e7fbe90a538b7fd4 Mon Sep 17 00:00:00 2001 From: allennakalema06-web Date: Thu, 15 Oct 2026 23:28:14 +0300 Subject: [PATCH 4/4] fix(formulus): add missing Swahili profile translation --- formulus/src/locales/sw.json | 1 + 1 file changed, 1 insertion(+) diff --git a/formulus/src/locales/sw.json b/formulus/src/locales/sw.json index 7fd2228dd..709a6a9ad 100644 --- a/formulus/src/locales/sw.json +++ b/formulus/src/locales/sw.json @@ -2,6 +2,7 @@ "profiles.title": "Wasifu", "profiles.trustWarning": "Hakikisha unaunganisha tu kwenye seva za Synkronus zinazoaminika. Msimbo wa programu maalum unaweza kufikia data kutoka wasifu mwingine kwenye kifaa hiki!", "profiles.closeTitle": "Funga Formulus kabisa", + "profiles.finishCreationFirst": "Kamilisha au ghairi wasifu wa sasa kabla ya kufungua kiungo kingine cha QR.", "profiles.closeMessage": "Uchaguzi wa wasifu wako umehifadhiwa; kubadilisha hakujafuta data yako. Funga Formulus kabisa kutoka kwa kibadilishaji cha programu, kisha uifungue tena ili kuendelea. Kwenda Nyumbani au kuiacha programu nyuma haitoshi. Wasifu wowote uliochagua kufuta umepangwa kufutwa ndani katika uzinduzi ujao kamili.", "profiles.recoveryTitle": "Haikuweza kufungua au kubadilisha wasifu kwa usalama", "profiles.recoveryMessage": "Funga Formulus kabisa kutoka kwa kibadilishaji cha programu, kisha uifungue tena. Kwenda Nyumbani au kuiacha programu nyuma haitoshi. Usifute data ya programu. Ikiwa hii inaendelea, wasiliana na usaidizi.",