Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion formulus/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<MainAppStackParamList> = {
prefixes: ['formulus://'],
config: {
screens: {
MainApp: {
screens: {
Profiles: 'settings',
},
},
},
},
};

/**
* Inner component that consumes the AppTheme context to build a dynamic
Expand Down Expand Up @@ -276,7 +291,7 @@ function AppInner(): React.JSX.Element {
backgroundColor={themeColors.surface}
/>
<DirtyExitGate />
<NavigationContainer theme={navigationTheme}>
<NavigationContainer theme={navigationTheme} linking={linking}>
<MainAppNavigator />
{formplayerStack.map((entry, index) => (
<FormplayerModal
Expand Down
10 changes: 10 additions & 0 deletions formulus/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:scheme="formulus"
android:host="settings" />
</intent-filter>
</activity>

<service
Expand Down
11 changes: 11 additions & 0 deletions formulus/ios/Formulus/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>org.opendataensemble.formulus</string>
<key>CFBundleURLSchemes</key>
<array>
<string>formulus</string>
</array>
</dict>
</array>
Comment on lines +13 to +23

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scheme is registered here, but I don't think iOS will hand the link to React Native yet. This app uses the scene lifecycle (UIApplicationSceneManifest + SceneDelegate), and SceneDelegate.swift only implements willConnectTo. With scenes, incoming URLs arrive through scene(_:openURLContexts:) (app already running) and connectionOptions.urlContexts (cold start), not application(_:open:options:). Neither is forwarded to RCTLinkingManager today.

Could we add that and test on an iOS device, or limit this PR to Android and say so in the description? Right now it reads as if iOS is supported, but it's untested.

<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
Expand Down
3 changes: 2 additions & 1 deletion formulus/scripts/generateQR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:',
Expand Down
6 changes: 5 additions & 1 deletion formulus/src/navigation/ProfileNavigationTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
38 changes: 36 additions & 2 deletions formulus/src/screens/ProfilesScreen.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -41,7 +46,8 @@ type LabelEditor = {
initialSettings?: SettingsUpdate;
};

const ProfilesScreen = () => {
type ProfilesScreenProps = BottomTabScreenProps<MainTabParamList, 'Profiles'>;
const ProfilesScreen = ({ route, navigation }: ProfilesScreenProps) => {
const { t } = useTranslation();
const { themeColors } = useAppTheme();
const shellStyle = useScreenShellStyle();
Expand All @@ -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)}`,
);
Comment on lines +76 to +78

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Navigation has already decoded payload here. We re-encode it, wrap it back into formulus://settings?payload=..., and then unwrapQRCode strips and decodes it again. It works, but it's a roundabout path.

Suggestion: add a method like QRSettingsService.parsePayload(payload) that takes the FRMLS string directly, and call that here. parseQRCode then only handles full scanned strings.


if (!mountedRef.current) return;

setDropdownOpen(false);
setEditor({ label: '', initialSettings: settings });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user already has the new-profile form open, a link replaces it and discards what they typed. Maybe skip the link, or ask first, when editor is already set.

} 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<RunProfileAction>(
async (operation, fallbackKey) => {
if (busyRef.current || !mountedRef.current) return;
Expand Down
19 changes: 18 additions & 1 deletion formulus/src/services/QRSettingsService.ts
Original file line number Diff line number Diff line change
@@ -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');
}
Comment on lines +6 to +12

@Mishael-2584 Mishael-2584 Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The strict startsWith(prefix) check fails on formulus://settings/?payload=..., and on links with extra params after the payload (...&foo=bar). In that second case the extra text becomes part of the payload and decoding returns wrong output.

Could we extract the value more flexibly (e.g. /[?&]payload=([^&#]*)/, or new URL() since react-native-url-polyfill is already a dependency)? Also, decodeURIComponent throws on a malformed % sequence, so a small test for that and for a trailing param would be good.


const encodedPayload = qrString.slice(prefix.length);
if (!encodedPayload) {
throw new Error('Missing FRMLS payload');
}

return decodeURIComponent(encodedPayload);
}
export interface SettingsUpdate {
serverUrl: string;
username: string;
Expand All @@ -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,
Expand Down
59 changes: 59 additions & 0 deletions formulus/src/services/__tests__/QRSettingsService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/// <reference types="jest" />

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');
});
});
3 changes: 2 additions & 1 deletion synkronus-cli/internal/cmd/qr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
3 changes: 2 additions & 1 deletion synkronus-portal/src/utils/frmls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes the portal emit only the new formulus:// link. Any Formulus build without this PR will reject these QR codes ("Not FRMLS format"), and field devices often update slowly.

Could we keep a way to generate the old format for a release or two (a toggle in the portal and a --legacy flag in synk qr), or at least note a minimum app version in the release notes? The same applies to qr.go and generateQR.ts.

}
Loading