Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
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
1 change: 1 addition & 0 deletions formulus/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions formulus/src/locales/sw.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
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
50 changes: 48 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,14 +46,20 @@ 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();
const { showConfirm } = useConfirmModal();

const { profiles, activeProfile } = useProfiles();
const [editor, setEditor] = useState<LabelEditor | null>(null);
const editorRef = useRef(editor);

useEffect(() => {
editorRef.current = editor;
}, [editor]);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
Expand All @@ -61,6 +72,41 @@ const ProfilesScreen = () => {
};
}, []);

useEffect(() => {
const payload = route?.params?.payload;
if (!payload) return;
if (editorRef.current) {
ToastService.showLong(t('profiles.finishCreationFirst'));
navigation.setParams({ payload: undefined });
return;
}

const processDeepLink = async () => {
try {
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 });
Comment thread
Mishael-2584 marked this conversation as resolved.
Resolved
} 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
107 changes: 105 additions & 2 deletions formulus/src/screens/__tests__/ProfilesScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -395,6 +409,7 @@ async function openQRCreation(screen: ReturnType<typeof render>) {
);
await confirm();
expect(screen.getByPlaceholderText('Profile name')).toHaveProp('value', '');
expect(ToastService.showLong).not.toHaveBeenCalled();
expect(profileRegistry.add).not.toHaveBeenCalled();
}

Expand Down Expand Up @@ -790,6 +805,94 @@ 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(
<ProfilesScreen route={route as any} navigation={mockNavigation as any} />,
);

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(ToastService.showLong).not.toHaveBeenCalled();

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(
<ProfilesScreen route={route as any} navigation={mockNavigation as any} />,
);

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(
<ProfilesScreen route={route as any} navigation={mockNavigation as any} />,
);

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',
Expand Down
Loading
Loading