diff --git a/.github/CICD.md b/.github/CICD.md index 0c4a2e20b..ac2150c1b 100644 --- a/.github/CICD.md +++ b/.github/CICD.md @@ -101,7 +101,7 @@ The workflow intelligently handles formplayer assets using two jobs: 1. **`build-formplayer-assets` job**: - Builds `@ode/tokens` - - Builds Formplayer assets using `npm run build:rn` in `formulus-formplayer` + - Builds Formplayer assets using `npm run build:copy` in `formulus-formplayer` - Uploads the built assets from `formulus/android/app/src/main/assets/formplayer_dist/` as a GitHub Actions artifact 2. **`build-android` job** (depends on assets job): @@ -310,15 +310,15 @@ For local development, you can manually build and copy assets: ```bash cd formulus-formplayer -npm run build:rn +npm run build:copy ``` This will: 1. Build the formplayer web app -2. Clean existing assets in formulus -3. Copy new assets to `formulus/android/app/src/main/assets/formplayer_dist/` +2. Clean existing formplayer asset folders in Formulus and copy new assets to Android and iOS paths +3. Copy the same bundle to `desktop/public/formplayer_dist/` for ODE Desktop -The `build:rn` script automatically handles cleaning, so no need to run `clean-rn-assets` separately. +The `copy-to-rn` step run inside `build:copy` handles cleaning targets before copy, so no need to run `clean-rn-assets` separately for a normal refresh. ## ODE Desktop (Tauri) @@ -326,20 +326,44 @@ The `build:rn` script automatically handles cleaning, so no need to run `clean-r ### Triggers -- **Pull requests** and **pushes** to `main` / `dev` when files under `desktop/**` change (or the workflow file itself) -- **Manual dispatch** +- **Pull requests** and **pushes** to `main` / `dev` when relevant paths change (see below), or **manual dispatch**. +- **`release: published`**: packages installers and attaches them to the GitHub Release (same pattern as [`Synkronus CLI`](workflows/synkronus-cli.yml); no path filter). ### Path filters -Runs only when `desktop/**` or `.github/workflows/ode-desktop.yml` changes. +For pull requests / pushes (`main`, `dev`), the workflow runs when any of these change: + +- `desktop/**` +- `formulus-formplayer/**` +- `packages/tokens/**`, `packages/components/**` (formplayer build inputs) +- `formulus/src/webview/FormulusInterfaceDefinition.ts` (formplayer `sync-interface` source) +- `.github/workflows/ode-desktop.yml` ### What it runs +**Job `desktop` (not on release)** + From `desktop/`: `pnpm lint`, `pnpm format:check`, `pnpm test`, `pnpm typecheck`, `pnpm codegen:synk-client`, then **fails** if `desktop/src/generated` drifts from the regenerated OpenAPI client. From `desktop/src-tauri/`: `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test`. +**Job `desktop-formplayer-dist`** (bundling and release flows) + +Ubuntu job: installs and builds `@ode/tokens`, runs `npm ci` / `npm run build` in `formulus-formplayer`, stages `build/` → `desktop/public/formplayer_dist/`, uploads artifact `desktop-formplayer-dist` (short retention for CI). + +**Jobs `build-desktop-bundles` (CI) and `release-desktop-bundles` (release)** + +Matrix build (mirrors CLI OS/arch coverage): **linux** amd64 + arm64, **windows** amd64 + arm64, **darwin** amd64 (`macos-15-intel`) + arm64 (`macos-latest`). Each runner installs Node + pnpm, restores formplayer artifact, installs Linux WebKitGTK packages where needed, runs `pnpm exec tauri build --target …` with a merged config so `beforeBuildCommand` runs **`pnpm build` only** (frontend + Vite output; embedded formplayer is already present). Builds use `Swatinem/rust-cache` scoped per platform. + +**CI artifacts** + +Each matrix cell uploads installers under artifact name `ode-desktop-` (files renamed with prefix `ode-desktop--`). + +**Release assets** + +On `release`, `softprops/action-gh-release` attaches those installers for each platform to the published release alongside other assets (CLI, APK, SBOMs, etc.). + ### Formplayer embed -After building formplayer (`npm run build` in `formulus-formplayer`), copy its `build/` into the desktop app with `pnpm copy:formplayer` from `desktop/` (see `desktop/README.md`). Copied assets are gitignored under `desktop/public/formplayer_dist/`. +Production bundles must include embedded formplayer: locally, `pnpm tauri build` uses `pnpm build:tauri` (`beforeBuildCommand` in `tauri.conf.json`). In CI/Rust release jobs, formplayer is built once on Ubuntu and copied into `desktop/public/formplayer_dist/` before each OS build. Copied assets are gitignored locally (see `desktop/README.md`). ## Future Enhancements diff --git a/.github/workflows/formulus-android.yml b/.github/workflows/formulus-android.yml index bb98cb11c..fe5efa53f 100644 --- a/.github/workflows/formulus-android.yml +++ b/.github/workflows/formulus-android.yml @@ -60,7 +60,7 @@ jobs: - name: Build and bundle formplayer working-directory: formulus-formplayer - run: npm run build:rn + run: npm run build:copy - name: Upload formplayer assets artifact uses: actions/upload-artifact@v6 diff --git a/.github/workflows/ode-desktop.yml b/.github/workflows/ode-desktop.yml index 28c615520..c642e5d8e 100644 --- a/.github/workflows/ode-desktop.yml +++ b/.github/workflows/ode-desktop.yml @@ -4,6 +4,10 @@ on: pull_request: paths: - 'desktop/**' + - 'formulus-formplayer/**' + - 'packages/tokens/**' + - 'packages/components/**' + - 'formulus/src/webview/FormulusInterfaceDefinition.ts' - '.github/workflows/ode-desktop.yml' push: branches: @@ -11,19 +15,33 @@ on: - dev paths: - 'desktop/**' + - 'formulus-formplayer/**' + - 'packages/tokens/**' + - 'packages/components/**' + - 'formulus/src/webview/FormulusInterfaceDefinition.ts' - '.github/workflows/ode-desktop.yml' + release: + types: [published] workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}-${{ github.event_name }} + cancel-in-progress: true + env: NODE_VERSION: '24' + # Frontend only: formplayer assets are supplied by desktop-formplayer-dist (avoids rebuilding on every OS). + TAURI_BEFORE_BUILD: '{"build":{"beforeBuildCommand":"pnpm build"}}' jobs: desktop: name: Lint, test, and Rust checks + if: github.event_name != 'release' runs-on: ubuntu-latest defaults: run: working-directory: desktop + shell: bash steps: - name: Checkout repository @@ -43,13 +61,13 @@ jobs: id: pnpm-cache shell: bash run: | - echo "STORE_PATH=$(pnpm store path $(pnpm --version))" >> $GITHUB_OUTPUT + echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" - name: Setup pnpm cache uses: actions/cache@v3 with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('desktop/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- @@ -87,7 +105,8 @@ jobs: librsvg2-dev \ patchelf \ libssl-dev \ - pkg-config + pkg-config \ + xdg-utils - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -100,3 +119,324 @@ jobs: cargo fmt --check cargo clippy -- -D warnings cargo test + + desktop-formplayer-dist: + name: Build embedded formplayer assets + runs-on: ubuntu-latest + defaults: + run: + shell: bash + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: | + formulus-formplayer/package-lock.json + packages/tokens/package-lock.json + + - name: Install and build @ode/tokens + working-directory: packages/tokens + run: | + npm ci + npm run build + + - name: Install formplayer dependencies + working-directory: formulus-formplayer + run: npm ci + + - name: Build formplayer + working-directory: formulus-formplayer + run: npm run build + + - name: Stage formplayer for desktop + run: | + set -euo pipefail + rm -rf desktop/public/formplayer_dist + mkdir -p desktop/public/formplayer_dist + cp -a formulus-formplayer/build/. desktop/public/formplayer_dist/ + + - name: Upload formplayer dist + uses: actions/upload-artifact@v7 + with: + name: desktop-formplayer-dist + path: desktop/public/formplayer_dist + retention-days: 7 + if-no-files-found: error + + build-desktop-bundles: + name: Package ODE Desktop (${{ matrix.platform }}) + if: github.event_name != 'release' + needs: desktop-formplayer-dist + runs-on: ${{ matrix.runner }} + defaults: + run: + shell: bash + + strategy: + fail-fast: false + matrix: + include: + - platform: linux-amd64 + runner: ubuntu-24.04 + rust_target: x86_64-unknown-linux-gnu + - platform: linux-arm64 + runner: ubuntu-24.04-arm + rust_target: aarch64-unknown-linux-gnu + - platform: windows-amd64 + runner: windows-latest + rust_target: x86_64-pc-windows-msvc + - platform: windows-arm64 + runner: windows-11-arm + rust_target: aarch64-pc-windows-msvc + - platform: darwin-amd64 + runner: macos-15-intel + rust_target: x86_64-apple-darwin + - platform: darwin-arm64 + runner: macos-latest + rust_target: aarch64-apple-darwin + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download embedded formplayer + uses: actions/download-artifact@v7 + with: + name: desktop-formplayer-dist + path: desktop/public/formplayer_dist + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Enable corepack and install pnpm + run: | + corepack enable + corepack prepare pnpm@latest --activate + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ hashFiles('desktop/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-pnpm- + + - name: Install desktop dependencies + working-directory: desktop + run: pnpm install --frozen-lockfile + + - name: Install Linux dependencies (Tauri prerequisites) + if: ${{ startsWith(matrix.runner, 'ubuntu') }} + run: | + sudo apt-get update + sudo apt-get install -y \ + libglib2.0-dev \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + pkg-config \ + xdg-utils + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust_target }} + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: desktop/src-tauri + shared-key: ode-desktop-${{ matrix.platform }} + + - name: Build Tauri bundles + working-directory: desktop + env: + CI: 'true' + run: >- + pnpm exec tauri build --target ${{ matrix.rust_target }} -c '${{ env.TAURI_BEFORE_BUILD }}' + + - name: Collect installers for upload + working-directory: desktop + run: | + set -euo pipefail + rm -rf dist-ci + mkdir -p dist-ci + bundle_dir="src-tauri/target/${{ matrix.rust_target }}/release/bundle" + if [[ ! -d "$bundle_dir" ]]; then + echo "::error::Missing bundle dir: $bundle_dir" + find src-tauri/target -maxdepth 4 -type d 2>/dev/null || true + exit 1 + fi + count=0 + while IFS= read -r -d '' f; do + base=$(basename "$f") + cp "$f" "dist-ci/ode-desktop-${{ matrix.platform }}-${base}" + count=$((count + 1)) + done < <(find "$bundle_dir" -type f \( \ + -name '*.AppImage' -o -name '*.deb' -o -name '*.dmg' -o -name '*.msi' -o -name '*.exe' -o -name '*.rpm' \ + \) -print0) + if [[ "$count" -eq 0 ]]; then + echo "::error::No bundle files matched in $bundle_dir" + find "$bundle_dir" -type f || true + exit 1 + fi + + - name: Upload installer artifact + uses: actions/upload-artifact@v7 + with: + name: ode-desktop-${{ matrix.platform }} + path: desktop/dist-ci/* + if-no-files-found: error + + release-desktop-bundles: + name: Publish ODE Desktop (${{ matrix.platform }}) + if: github.event_name == 'release' + needs: desktop-formplayer-dist + runs-on: ${{ matrix.runner }} + permissions: + contents: write + + defaults: + run: + shell: bash + + strategy: + fail-fast: false + matrix: + include: + - platform: linux-amd64 + runner: ubuntu-24.04 + rust_target: x86_64-unknown-linux-gnu + - platform: linux-arm64 + runner: ubuntu-24.04-arm + rust_target: aarch64-unknown-linux-gnu + - platform: windows-amd64 + runner: windows-latest + rust_target: x86_64-pc-windows-msvc + - platform: windows-arm64 + runner: windows-11-arm + rust_target: aarch64-pc-windows-msvc + - platform: darwin-amd64 + runner: macos-15-intel + rust_target: x86_64-apple-darwin + - platform: darwin-arm64 + runner: macos-latest + rust_target: aarch64-apple-darwin + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download embedded formplayer + uses: actions/download-artifact@v7 + with: + name: desktop-formplayer-dist + path: desktop/public/formplayer_dist + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Enable corepack and install pnpm + run: | + corepack enable + corepack prepare pnpm@latest --activate + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ hashFiles('desktop/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-pnpm- + + - name: Install desktop dependencies + working-directory: desktop + run: pnpm install --frozen-lockfile + + - name: Install Linux dependencies (Tauri prerequisites) + if: ${{ startsWith(matrix.runner, 'ubuntu') }} + run: | + sudo apt-get update + sudo apt-get install -y \ + libglib2.0-dev \ + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + pkg-config \ + xdg-utils + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.rust_target }} + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: desktop/src-tauri + shared-key: ode-desktop-release-${{ matrix.platform }} + + - name: Build Tauri bundles + working-directory: desktop + env: + CI: 'true' + run: >- + pnpm exec tauri build --target ${{ matrix.rust_target }} -c '${{ env.TAURI_BEFORE_BUILD }}' + + - name: Collect installers for GitHub Release + working-directory: desktop + run: | + set -euo pipefail + rm -rf dist-release + mkdir -p dist-release + bundle_dir="src-tauri/target/${{ matrix.rust_target }}/release/bundle" + if [[ ! -d "$bundle_dir" ]]; then + echo "::error::Missing bundle dir: $bundle_dir" + find src-tauri/target -maxdepth 4 -type d 2>/dev/null || true + exit 1 + fi + count=0 + while IFS= read -r -d '' f; do + base=$(basename "$f") + cp "$f" "dist-release/ode-desktop-${{ matrix.platform }}-${base}" + count=$((count + 1)) + done < <(find "$bundle_dir" -type f \( \ + -name '*.AppImage' -o -name '*.deb' -o -name '*.dmg' -o -name '*.msi' -o -name '*.exe' -o -name '*.rpm' \ + \) -print0) + if [[ "$count" -eq 0 ]]; then + echo "::error::No bundle files matched in $bundle_dir" + find "$bundle_dir" -type f || true + exit 1 + fi + + - name: Attach installers to Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name }} + files: desktop/dist-release/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 41869931d..88f195ccc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ Do not assume custom app authors have local checkouts of **ODE** or internal exa ## Cross-cutting contracts - **Formulus ↔ WebView (custom apps + formplayer):** [`formulus/src/webview/FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts) is the **source of truth** for the injected JavaScript API. Formplayer copies a synced TypeScript snapshot via `npm run sync-interface` in `formulus-formplayer` (see [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md)). +- **Built-in attachment fields:** `photo`, `audio`, `video`, and generic file (`select_file`) persist attachment **basenames** (and metadata) in observation JSON while binaries live under Formulus **`attachments/`** storage and sync via the attachment pipeline—see published docs ([form specifications](https://opendataensemble.org/docs/reference/form-specifications), [form design guide](https://opendataensemble.org/docs/guides/form-design)) and [`FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts). - **Shared UI tokens:** Install **tokens** before **components** / **formplayer** where the docs require it (see package READMEs and formplayer AGENTS). --- diff --git a/desktop/README.md b/desktop/README.md index 9415dc8ee..be4f53ddf 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -47,19 +47,19 @@ pnpm tauri dev ## Scripts -| Script | Purpose | -| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pnpm dev` | Vite dev server (frontend). | -| `pnpm build` | Typecheck + Vite production build. | -| `pnpm build:formplayer` | Build `../formulus-formplayer` and copy output into `public/formplayer_dist/`. | -| `pnpm build:tauri` | Prepare Formplayer assets (`build:formplayer`) and then run the desktop frontend build. | -| `pnpm tauri build` | Full desktop bundle; automatically runs `pnpm build:tauri` first, so the packaged app includes `formplayer_dist`. | -| `pnpm lint` / `pnpm lint:fix` | ESLint. | -| `pnpm format` / `pnpm format:check` | Prettier. | -| `pnpm test` | Vitest (unit / component tests). | -| `pnpm typecheck` | `tsc --noEmit`. | -| `pnpm codegen:synk-client` | Regenerate TypeScript client from Synkronus OpenAPI. | -| `pnpm copy:formplayer` | Copy `../formulus-formplayer/build/` → `public/formplayer_dist/`. Prefer from `formulus-formplayer/`: `npm run build:ode-desktop` (build + RN + desktop copy). | +| Script | Purpose | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm dev` | Vite dev server (frontend). | +| `pnpm build` | Typecheck + Vite production build. | +| `pnpm build:formplayer` | Build `../formulus-formplayer` and copy output into `public/formplayer_dist/`. | +| `pnpm build:tauri` | Prepare Formplayer assets (`build:formplayer`) and then run the desktop frontend build. | +| `pnpm tauri build` | Full desktop bundle; automatically runs `pnpm build:tauri` first, so the packaged app includes `formplayer_dist`. | +| `pnpm lint` / `pnpm lint:fix` | ESLint. | +| `pnpm format` / `pnpm format:check` | Prettier. | +| `pnpm test` | Vitest (unit / component tests). | +| `pnpm typecheck` | `tsc --noEmit`. | +| `pnpm codegen:synk-client` | Regenerate TypeScript client from Synkronus OpenAPI. | +| `pnpm copy:formplayer` | Copy `../formulus-formplayer/build/` → `public/formplayer_dist/`. Prefer from `formulus-formplayer/`: `npm run build:copy` (build + Formulus + desktop copy). | ### Rust (backend) @@ -87,7 +87,7 @@ CI regenerates the client and **fails** if the repo does not match (`ode-desktop ## Architecture pointers - **Bridge contract**: [`formulus/src/webview/FormulusInterfaceDefinition.ts`](../formulus/src/webview/FormulusInterfaceDefinition.ts) — source of truth for `formulusAPI` / postMessage. After changes, run **`sync-interface`** in `formulus-formplayer` and mirror behavior in the desktop WebView host. -- **Form preview host** (Workbench → Form preview): `public/formulus-injection.js` + iframe shim; parent handles `postMessage` in **`src/lib/formPreviewBridge.ts`** (explicit matrix per `FormulusInjectionScript` request `type`; device APIs stubbed, observations + URIs use Tauri where applicable). Nested **sub-observation** flows (`openFormplayer` + `options.subObservationMode`) open a stacked Form preview iframe and resolve the parent promise with `FormCompletionResult` without persisting the child as a top-level observation. +- **Form preview host** (Workbench → Form preview): `public/formulus-injection.js` + iframe shim; parent handles `postMessage` in **`src/lib/formPreviewBridge.ts`** (explicit matrix per `FormulusInjectionScript` request `type`; device APIs including camera, audio, and video are stubbed in preview; observations + URIs use Tauri where applicable). Nested **sub-observation** flows (`openFormplayer` + `options.subObservationMode`) open a stacked Form preview iframe and resolve the parent promise with `FormCompletionResult` without persisting the child as a top-level observation. - **Bundle extensions**: merge rules for `forms/ext.json` and `forms/{form}/ext.json` follow Formulus `ExtensionService`; see `src/lib/bundleResolution.ts`. - **Embedded formplayer**: production build copied into `public/formplayer_dist/`; load in a WebView with the same **`FormInitData`** expectations as mobile (see `src/lib/formplayerHost.ts` for placeholder types). diff --git a/desktop/public/formulus-injection.js b/desktop/public/formulus-injection.js index 1a6743ec4..66b396468 100644 --- a/desktop/public/formulus-injection.js +++ b/desktop/public/formulus-injection.js @@ -1,38 +1,51 @@ // Auto-generated from FormulusInterfaceDefinition.ts // Do not edit directly - this file will be overwritten -// Last generated: 2026-05-02T14:24:11.519Z +// Last generated: 2026-05-02T16:53:14.030Z -(function() { +(function () { // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist - return globalThis.formulus || (typeof window !== 'undefined' ? window.formulus : undefined); + return ( + globalThis.formulus || + (typeof window !== 'undefined' ? window.formulus : undefined) + ); } function isFormulusAvailable() { const api = getFormulus(); - return api && typeof api === 'object' && typeof api.getVersion === 'function'; + return ( + api && typeof api === 'object' && typeof api.getVersion === 'function' + ); } // Idempotent guard to avoid double-initialization when scripts are reinjected - if ((globalThis).__formulusBridgeInitialized) { + if (globalThis.__formulusBridgeInitialized) { if (isFormulusAvailable()) { - console.debug('Formulus bridge already initialized and functional. Skipping duplicate injection.'); + console.debug( + 'Formulus bridge already initialized and functional. Skipping duplicate injection.', + ); return; } else { - console.warn('Formulus bridge flag is set but API is not functional. Proceeding with re-injection...'); + console.warn( + 'Formulus bridge flag is set but API is not functional. Proceeding with re-injection...', + ); } } // If API already exists and is functional, skip injection if (isFormulusAvailable()) { - console.debug('Formulus interface already exists and is functional. Skipping injection.'); + console.debug( + 'Formulus interface already exists and is functional. Skipping injection.', + ); return; } // If API exists but is not functional, log warning and proceed with re-injection if (getFormulus()) { - console.warn('Formulus interface exists but appears non-functional. Re-injecting...'); + console.warn( + 'Formulus interface exists but appears non-functional. Re-injecting...', + ); } // Helper function to handle callbacks @@ -48,7 +61,7 @@ // Initialize callbacks const callbacks = {}; - + // Global function to handle responses from React Native function handleMessage(event) { try { @@ -61,1201 +74,1599 @@ // console.warn('Global handleMessage: Received message with unexpected data type:', typeof event.data, event.data); return; // Or handle error, but for now, just return to avoid breaking others. } - + // Handle callbacks - if (data.type === 'callback' && data.callbackId && callbacks[data.callbackId]) { + if ( + data.type === 'callback' && + data.callbackId && + callbacks[data.callbackId] + ) { handleCallback(callbacks[data.callbackId], data.data); delete callbacks[data.callbackId]; } - + // Handle specific callbacks - - - if (data.type === 'onFormulusReady' && globalThis.formulusCallbacks?.onFormulusReady) { + + if ( + data.type === 'onFormulusReady' && + globalThis.formulusCallbacks?.onFormulusReady + ) { handleCallback(globalThis.formulusCallbacks.onFormulusReady); } } catch (e) { - console.error('Global handleMessage: Error processing message:', e, 'Raw event.data:', event.data); + console.error( + 'Global handleMessage: Error processing message:', + e, + 'Raw event.data:', + event.data, + ); } } - + // Set up message listener document.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage); // Initialize the formulus interface globalThis.formulus = { - // getVersion: => Promise - getVersion: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + // getVersion: => Promise + getVersion: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getVersion callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getVersion_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getVersion callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getVersion_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getVersion' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getVersion' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getVersion', messageId, - - })); - - }); - }, - - // getAvailableForms: => Promise - getAvailableForms: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getAvailableForms: => Promise + getAvailableForms: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAvailableForms callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAvailableForms_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAvailableForms callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getAvailableForms_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getAvailableForms' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getAvailableForms' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAvailableForms', messageId, - - })); - - }); - }, - - // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; } => Promise - openFormplayer: function(formType, params, savedData, options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; } => Promise + openFormplayer: function (formType, params, savedData, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'openFormplayer callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'openFormplayer_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('openFormplayer callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'openFormplayer_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'openFormplayer' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'openFormplayer' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'openFormplayer', messageId, - formType: formType, + formType: formType, params: params, savedData: savedData, - options: options - })); - - }); - }, - - // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise - getObservations: function(formType, isDraft, includeDeleted) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise + getObservations: function (formType, isDraft, includeDeleted) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getObservations callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getObservations_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getObservations callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getObservations_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getObservations' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getObservations' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getObservations', messageId, - formType: formType, + formType: formType, isDraft: isDraft, - includeDeleted: includeDeleted - })); - - }); - }, - - // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; whereClause?: string; } => Promise - getObservationsByQuery: function(options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + includeDeleted: includeDeleted, + }), + ); + }); + }, + + // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; whereClause?: string; } => Promise + getObservationsByQuery: function (options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getObservationsByQuery_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getObservationsByQuery_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getObservationsByQuery' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getObservationsByQuery' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getObservationsByQuery', messageId, - options: options - })); - - }); - }, - - // submitObservation: formType: string, finalData: Record => Promise - submitObservation: function(formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // submitObservation: formType: string, finalData: Record => Promise + submitObservation: function (formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'submitObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'submitObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('submitObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'submitObservation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'submitObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'submitObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'submitObservation', messageId, - formType: formType, - finalData: finalData - })); - - }); - }, - - // updateObservation: observationId: string, formType: string, finalData: Record => Promise - updateObservation: function(observationId, formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + formType: formType, + finalData: finalData, + }), + ); + }); + }, + + // updateObservation: observationId: string, formType: string, finalData: Record => Promise + updateObservation: function (observationId, formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'updateObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'updateObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('updateObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'updateObservation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'updateObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'updateObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'updateObservation', messageId, - observationId: observationId, + observationId: observationId, formType: formType, - finalData: finalData - })); - - }); - }, - - // requestCamera: fieldId: string => Promise - requestCamera: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + finalData: finalData, + }), + ); + }); + }, + + // requestCamera: fieldId: string => Promise + requestCamera: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestCamera callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestCamera_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestCamera callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestCamera_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestCamera' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestCamera' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestCamera', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestLocation: fieldId: string => Promise - requestLocation: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestLocation: fieldId: string => Promise + requestLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestLocation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestLocation', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestFile: fieldId: string => Promise - requestFile: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestFile: fieldId: string => Promise + requestFile: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestFile callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestFile_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestFile callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestFile_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestFile' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestFile' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestFile', messageId, - fieldId: fieldId - })); - - }); - }, - - // launchIntent: fieldId: string, intentSpec: Record => Promise - launchIntent: function(fieldId, intentSpec) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // launchIntent: fieldId: string, intentSpec: Record => Promise + launchIntent: function (fieldId, intentSpec) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'launchIntent callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'launchIntent_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('launchIntent callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'launchIntent_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'launchIntent' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'launchIntent' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'launchIntent', messageId, - fieldId: fieldId, - intentSpec: intentSpec - })); - - }); - }, - - // callSubform: fieldId: string, formType: string, options: Record => Promise - callSubform: function(fieldId, formType, options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + intentSpec: intentSpec, + }), + ); + }); + }, + + // callSubform: fieldId: string, formType: string, options: Record => Promise + callSubform: function (fieldId, formType, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'callSubform callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'callSubform_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('callSubform callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'callSubform_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'callSubform' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'callSubform' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'callSubform', messageId, - fieldId: fieldId, + fieldId: fieldId, formType: formType, - options: options - })); - - }); - }, - - // requestAudio: fieldId: string => Promise - requestAudio: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // requestAudio: fieldId: string => Promise + requestAudio: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestAudio callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestAudio_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestAudio callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestAudio_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestAudio' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestAudio' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestAudio', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestQrcode: fieldId: string => Promise - requestQrcode: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestVideo: fieldId: string => Promise + requestVideo: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; + } else { + window.removeEventListener('message', callback); + reject( + new Error( + 'requestVideo callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestVideo_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestQrcode callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestQrcode_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + } + } catch (e) { + console.error( + "'requestVideo' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); + reject(e); + } + }; + window.addEventListener('message', callback); + + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'requestVideo', + messageId, + fieldId: fieldId, + }), + ); + }); + }, + + // requestQrcode: fieldId: string => Promise + requestQrcode: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestQrcode callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestQrcode_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); } - } catch (e) { - console.error("'requestQrcode' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestQrcode' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestQrcode', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestBiometric: fieldId: string => Promise - requestBiometric: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestBiometric: fieldId: string => Promise + requestBiometric: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestBiometric callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestBiometric_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestBiometric callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestBiometric_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestBiometric' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestBiometric' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestBiometric', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestConnectivityStatus: => Promise - requestConnectivityStatus: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestConnectivityStatus: => Promise + requestConnectivityStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestConnectivityStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestConnectivityStatus_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestConnectivityStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestConnectivityStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestConnectivityStatus', messageId, - - })); - - }); - }, - - // requestSyncStatus: => Promise - requestSyncStatus: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // requestSyncStatus: => Promise + requestSyncStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestSyncStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestSyncStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestSyncStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestSyncStatus_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestSyncStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'requestSyncStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestSyncStatus', messageId, - - })); - - }); - }, - - // runLocalModel: fieldId: string, modelId: string, input: Record => Promise - runLocalModel: function(fieldId, modelId, input) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // runLocalModel: fieldId: string, modelId: string, input: Record => Promise + runLocalModel: function (fieldId, modelId, input) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'runLocalModel callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'runLocalModel_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('runLocalModel callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'runLocalModel_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'runLocalModel' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'runLocalModel' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'runLocalModel', messageId, - fieldId: fieldId, + fieldId: fieldId, modelId: modelId, - input: input - })); - - }); - }, - - // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> - getCurrentUser: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + input: input, + }), + ); + }); + }, + + // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> + getCurrentUser: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCurrentUser callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCurrentUser_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCurrentUser callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getCurrentUser_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getCurrentUser' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getCurrentUser' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCurrentUser', messageId, - - })); - - }); - }, - - // getThemeMode: => Promise<"light" | "dark" | "system"> - getThemeMode: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getThemeMode: => Promise<"light" | "dark" | "system"> + getThemeMode: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getThemeMode callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getThemeMode_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getThemeMode callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getThemeMode_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getThemeMode' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getThemeMode' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getThemeMode', messageId, - - })); - - }); - }, - - // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise - getAttachmentUri: function(fileName) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise + getAttachmentUri: function (fileName) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAttachmentUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAttachmentUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAttachmentUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getAttachmentUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getAttachmentUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getAttachmentUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAttachmentUri', messageId, - fileName: fileName - })); - - }); - }, - - // getAttachmentsUri: => Promise - getAttachmentsUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fileName: fileName, + }), + ); + }); + }, + + // getAttachmentsUri: => Promise + getAttachmentsUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAttachmentsUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getAttachmentsUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getAttachmentsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getAttachmentsUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAttachmentsUri', messageId, - - })); - - }); - }, - - // getCustomAppUri: => Promise - getCustomAppUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getCustomAppUri: => Promise + getCustomAppUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCustomAppUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCustomAppUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCustomAppUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getCustomAppUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getCustomAppUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getCustomAppUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCustomAppUri', messageId, - - })); - - }); - }, - - // getFormSpecsUri: => Promise - getFormSpecsUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getFormSpecsUri: => Promise + getFormSpecsUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getFormSpecsUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getFormSpecsUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getFormSpecsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } catch (e) { + console.error( + "'getFormSpecsUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getFormSpecsUri', messageId, - - })); - - }); - }, + }), + ); + }); + }, }; - + // Register the callback handler with the window object globalThis.formulusCallbacks = {}; - + // Notify that the interface is ready console.log('Formulus interface initialized'); - (globalThis).__formulusBridgeInitialized = true; + globalThis.__formulusBridgeInitialized = true; // Simple API availability check for internal use function requestApiReinjection() { console.log('Formulus: Requesting re-injection from host...'); if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'requestApiReinjection', - timestamp: Date.now() - })); + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'requestApiReinjection', + timestamp: Date.now(), + }), + ); } } globalThis.__formulusRequestApiReinjection = requestApiReinjection; // Notify React Native that the interface is ready if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'onFormulusReady' - })); + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'onFormulusReady', + }), + ); } - + // Make the API available globally in browser environments if (typeof window !== 'undefined') { window.formulus = globalThis.formulus; } - })(); diff --git a/desktop/src/components/FormplayerEmbed.tsx b/desktop/src/components/FormplayerEmbed.tsx index b9e5d19be..e655d2a4d 100644 --- a/desktop/src/components/FormplayerEmbed.tsx +++ b/desktop/src/components/FormplayerEmbed.tsx @@ -31,7 +31,7 @@ function assertFormplayerIndexHtml(html: string): void { if (looksLikeDesktopShell || looksLikeDevShell || !looksLikeFormplayer) { throw new Error( - 'formplayer_dist/index.html is missing or the app shell HTML was returned instead of the Formplayer bundle. Run pnpm copy:formplayer from desktop/ or npm run build:ode-desktop from formulus-formplayer.', + 'formplayer_dist/index.html is missing or the app shell HTML was returned instead of the Formplayer bundle. Run pnpm copy:formplayer from desktop/ or npm run build:copy from formulus-formplayer.', ); } } diff --git a/desktop/src/lib/__tests__/formPreviewBridge.test.ts b/desktop/src/lib/__tests__/formPreviewBridge.test.ts index 1e1c1de93..bc85b745c 100644 --- a/desktop/src/lib/__tests__/formPreviewBridge.test.ts +++ b/desktop/src/lib/__tests__/formPreviewBridge.test.ts @@ -48,6 +48,7 @@ describe('FORMULUS_INJECTION_REQUEST_TYPES', () => { it('lists known injection request types', () => { expect(FORMULUS_INJECTION_REQUEST_TYPES).toContain('getVersion'); expect(FORMULUS_INJECTION_REQUEST_TYPES).toContain('submitObservation'); + expect(FORMULUS_INJECTION_REQUEST_TYPES).toContain('requestVideo'); }); }); @@ -206,6 +207,29 @@ describe('handleFormPreviewBridgeMessage', () => { expect(payload.error).toContain(DESKTOP_FORM_PREVIEW_PREFIX); }); + it('stubs requestVideo with prefixed error', async () => { + const postMessage = vi.fn(); + const cw = { postMessage } as unknown as Window; + const iframe = { + contentWindow: cw, + } as HTMLIFrameElement; + + await handleFormPreviewBridgeMessage( + bridgeMessageFromIframe(iframe, { + type: 'requestVideo', + messageId: 'mv-prev', + }), + { + iframe, + onFinalize: async () => ({ error: 'no' }), + }, + ); + + const payload = JSON.parse(postMessage.mock.calls[0][0] as string); + expect(payload.type).toBe('requestVideo_response'); + expect(payload.error).toContain(DESKTOP_FORM_PREVIEW_PREFIX); + }); + it('getAttachmentUri maps file:// through convertFileSrc for iframe img', async () => { vi.mocked(tauriClient.workspaceAttachmentFileUrl).mockResolvedValueOnce( 'file:///home/u/ws/attachments/synced/a.jpg', diff --git a/desktop/src/lib/formPreviewBridge.ts b/desktop/src/lib/formPreviewBridge.ts index d84a25751..c57a7412e 100644 --- a/desktop/src/lib/formPreviewBridge.ts +++ b/desktop/src/lib/formPreviewBridge.ts @@ -11,7 +11,7 @@ * | `getObservations` | Local SQLite via `listObservationsPage`. | * | `getObservationsByQuery` | Same + best-effort `whereClause` filter (`formulus-load.js` flattens options). | * | `submitObservation` / `updateObservation` | Finalize dialog (JSON export or DB). | - * | `requestCamera` / `requestLocation` / `requestFile` / `requestAudio` / `requestQrcode` / `requestBiometric` | **Stub** — no device bridge in preview. | + * | `requestCamera` / `requestLocation` / `requestFile` / `requestAudio` / `requestVideo` / `requestQrcode` / `requestBiometric` | **Stub** — no device bridge in preview. | * | `launchIntent` / `callSubform` | **Stub** — not supported in preview. | * | `requestConnectivityStatus` / `requestSyncStatus` | **No-op** success (`result` omitted) so callers resolve. | * | `runLocalModel` | **Stub** — no on-device ML in preview. | @@ -56,6 +56,7 @@ export const FORMULUS_INJECTION_REQUEST_TYPES = [ 'launchIntent', 'callSubform', 'requestAudio', + 'requestVideo', 'requestQrcode', 'requestBiometric', 'requestConnectivityStatus', @@ -551,6 +552,15 @@ export async function handleFormPreviewBridgeMessage( ); return; + case 'requestVideo': + reply( + 'requestVideo', + stubReason( + 'Video recording is not available in ODE Desktop form preview.', + ), + ); + return; + case 'requestQrcode': reply( 'requestQrcode', diff --git a/desktop/src/pages/FormPreviewPage.tsx b/desktop/src/pages/FormPreviewPage.tsx index 57c89889e..df7a79f03 100644 --- a/desktop/src/pages/FormPreviewPage.tsx +++ b/desktop/src/pages/FormPreviewPage.tsx @@ -494,9 +494,8 @@ export function FormPreviewPage() { defaultData) and saved data for edit-style payloads — same shape as FormInitData in{' '} FormulusInterfaceDefinition. Build formplayer from{' '} - formulus-formplayer/ ( - npm run build:ode-desktop) or{' '} - pnpm copy:formplayer in desktop/. + formulus-formplayer/ (npm run build:copy) + or pnpm copy:formplayer in desktop/.

diff --git a/formulus-formplayer/.gitignore b/formulus-formplayer/.gitignore index 4d29575de..95a91f152 100644 --- a/formulus-formplayer/.gitignore +++ b/formulus-formplayer/.gitignore @@ -10,6 +10,7 @@ # production /build +/storybook-static # misc .DS_Store diff --git a/formulus-formplayer/.storybook/main.ts b/formulus-formplayer/.storybook/main.ts index d145d3bb6..0e3fe5cab 100644 --- a/formulus-formplayer/.storybook/main.ts +++ b/formulus-formplayer/.storybook/main.ts @@ -7,6 +7,9 @@ const config: StorybookConfig = { name: '@storybook/react-vite', options: {}, }, + core: { + disableTelemetry: true, + }, }; export default config; diff --git a/formulus-formplayer/.storybook/preview.tsx b/formulus-formplayer/.storybook/preview.tsx index f8e404d95..e6f6be78b 100644 --- a/formulus-formplayer/.storybook/preview.tsx +++ b/formulus-formplayer/.storybook/preview.tsx @@ -2,6 +2,13 @@ import React from 'react'; import type { Preview } from '@storybook/react-vite'; import { ThemeProvider, CssBaseline } from '@mui/material'; import { theme } from '../src/theme/theme'; +import FormulusClient from '../src/services/FormulusInterface'; + +/** Ensure each story's `window.getFormulus` mock is picked up (singleton cache). */ +function ClearFormulusBridgeCache({ children }: { children: React.ReactNode }) { + FormulusClient.clearCachedFormulusApi(); + return <>{children}; +} const preview: Preview = { parameters: { @@ -18,7 +25,9 @@ const preview: Preview = {
- + + +
), diff --git a/formulus-formplayer/AGENTS.md b/formulus-formplayer/AGENTS.md index f2e13c4f7..0fdcd4f6a 100644 --- a/formulus-formplayer/AGENTS.md +++ b/formulus-formplayer/AGENTS.md @@ -20,14 +20,13 @@ This file gives AI assistants and developers enough context to work effectively `cd packages/tokens && npm install` then `cd formulus-formplayer && npm install && npm start`. Installing only in formulus-formplayer can break the tokens `prepare` script. -## Build and deploy (RN) +## Build and deploy (Formulus & Desktop) - **Scripts** (from `formulus-formplayer/`): - `npm run build` — `sync-interface` → `tsc` → `vite build` (output: `build/`). - - `npm run build:rn` — build then **copy** `build/` into the Formulus app: + - `npm run build:copy` — build then **copy** `build/` into **Formulus** (Android + iOS formplayer assets) and **ODE Desktop** (`../desktop/public/formplayer_dist/`). Alternatively, from `desktop/` only: `pnpm copy:formplayer` (requires an existing `formulus-formplayer/build/`). - Android: `../formulus/android/app/src/main/assets/formplayer_dist/` - iOS: `../formulus/ios/formplayer_dist/` - - `npm run build:ode-desktop` — **one command**: same as `build:rn`, then copies `build/` into **`../desktop/public/formplayer_dist/`** for ODE Desktop (Tauri). Use this when you need both React Native assets and the desktop embed refreshed. Alternatively, from `desktop/` only: `pnpm copy:formplayer` (requires an existing `formulus-formplayer/build/`). - **Interface sync**: `scripts/sync-interface.js` copies **one** shared TypeScript file from the Formulus app into the formplayer: `formulus/src/webview/FormulusInterfaceDefinition.ts` → `formulus-formplayer/src/types/FormulusInterfaceDefinition.ts`. So the **single source of truth** for the bridge contract is in **formulus**; formplayer consumes a copy. Run `npm run sync-interface` (or `npm run build`) when that file changes. @@ -58,6 +57,7 @@ This file gives AI assistants and developers enough context to work effectively 2. **Bridge**: Communication with RN is via **postMessage** and the contract in `FormulusInterfaceDefinition.ts`. The formplayer uses `FormulusClient` (singleton) in `FormulusInterface.ts` to call native (camera, signature, submit, etc.). 3. **Custom question types**: Loaded from a **manifest** (source strings) from the RN app, evaluated in a sandbox with `React` and `MaterialUI` on `window`. They use **format** in the schema (e.g. `"format": "signature"`), not only `type`. Contract: `src/types/CustomQuestionTypeContract.ts`. 4. **Design tokens**: Use `@ode/tokens` via `src/theme/tokens-adapter.ts` and the theme in `src/theme/theme.ts`; avoid hardcoding colors/spacing that exist in tokens. +5. **Attachment-backed builtins** (`photo`, `audio`, `video`, `select_file`): Observation JSON stores **basename-only** `filename` plus portable metadata; RN writes files under **`attachments/draft/`** (etc.). Resolve previews with **`getAttachmentUri`** where applicable. **`select_file`** shows the chosen **name** only—no file preview. ## Adding or changing behavior diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index 5048f9a29..874ff5aac 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -29,7 +29,7 @@ If you run `npm install` only in formulus-formplayer, the tokens package’s `pr ## Building this project -Use 'npm run build:rn' to build the project. This will build the project and copy the build to the formulus app. +Use `npm run build:copy` to build the project and copy the bundle into the Formulus app (Android + iOS) and ODE Desktop (`desktop/public/formplayer_dist/`). ## Javascript interface diff --git a/formulus-formplayer/eslint.config.js b/formulus-formplayer/eslint.config.js index 7d72691fe..678ac758d 100644 --- a/formulus-formplayer/eslint.config.js +++ b/formulus-formplayer/eslint.config.js @@ -16,6 +16,7 @@ export default defineConfig([ '**/coverage/**', '**/__tests__/**', '**/scripts/**', + '**/storybook-static/**', ]), js.configs.recommended, ...tseslint.configs.recommended, diff --git a/formulus-formplayer/package-lock.json b/formulus-formplayer/package-lock.json index 3f9a72d8a..504a0c959 100644 --- a/formulus-formplayer/package-lock.json +++ b/formulus-formplayer/package-lock.json @@ -6485,9 +6485,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "dev": true, "funding": [ { @@ -7821,9 +7821,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/formulus-formplayer/package.json b/formulus-formplayer/package.json index 10a7b03a1..7081268cc 100644 --- a/formulus-formplayer/package.json +++ b/formulus-formplayer/package.json @@ -38,9 +38,8 @@ "sync-interface": "node scripts/sync-interface.js", "build": "npm run sync-interface && tsc && vite build", "preview": "vite preview", - "upload:android": "npm run build:rn", - "build:rn": "npm run build && npm run copy-to-rn", - "build:ode-desktop": "npm run build && npm run copy-to-rn && node ../desktop/scripts/copy-formplayer-to-desktop.mjs", + "upload:android": "npm run build:copy", + "build:copy": "npm run build && npm run copy-to-rn && node ../desktop/scripts/copy-formplayer-to-desktop.mjs", "clean-rn-assets": "node scripts/clean-rn-assets.js", "copy-to-rn": "node scripts/copy-to-rn.js", "test": "vitest", diff --git a/formulus-formplayer/src/mocks/webview-mock.ts b/formulus-formplayer/src/mocks/webview-mock.ts index 92234b848..87c3744b4 100644 --- a/formulus-formplayer/src/mocks/webview-mock.ts +++ b/formulus-formplayer/src/mocks/webview-mock.ts @@ -6,17 +6,9 @@ import { FileResult, AudioResult, LocationResult, + VideoResult, } from '../types/FormulusInterfaceDefinition'; -// Local lightweight type for video results used only in the development mock. - -type VideoResult = { - fieldId: string; - status: 'success' | 'cancelled' | 'error'; - message?: string; - data?: any; -}; - interface MockWebView { postMessage: (message: string) => void; } @@ -98,6 +90,42 @@ class WebViewMock { } >(); + /** + * basename → WebView-loadable URL (browser dev). + * Mirrors RN behaviour: capture registers the file, previews resolve via getAttachmentUri only. + */ + private attachmentDisplayUrlsByBasename = new Map(); + + private basenameFromAttachmentRef( + fileRef: string | { filename?: string }, + ): string | null { + const raw = + typeof fileRef === 'string' + ? fileRef + : typeof fileRef.filename === 'string' + ? fileRef.filename + : ''; + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const base = trimmed.replace(/\\/g, '/').split('/').pop()?.trim() ?? ''; + if (!base || base === '.' || base === '..' || base.includes('..')) { + return null; + } + return base; + } + + private resolveMockAttachmentDisplayUrl( + fileRef: string | { filename?: string }, + ): string | null { + const base = this.basenameFromAttachmentRef(fileRef); + if (!base) { + return null; + } + return this.attachmentDisplayUrlsByBasename.get(base) ?? null; + } + // Mock the postMessage function that the app uses to send messages to native private postMessage = (message: string) => { try { @@ -322,12 +350,16 @@ class WebViewMock { }); }, getAttachmentUri: ( - _fileRef: string | { filename?: string }, + fileRef: string | { filename?: string }, ): Promise => { + const resolved = this.resolveMockAttachmentDisplayUrl(fileRef); console.log( - '[WebView Mock] getAttachmentUri (browser dev: no local files)', + '[WebView Mock] getAttachmentUri', + fileRef, + '→', + resolved, ); - return Promise.resolve(null); + return Promise.resolve(resolved); }, launchIntent: ( fieldId: string, @@ -635,8 +667,11 @@ class WebViewMock { }; const imageGuid = generateGUID(); - // Use the actual dummy photo from public folder for browser testing + const basename = `${imageGuid}.jpg`; const dummyPhotoUrl = `${window.location.origin}/dummyphoto.png`; + this.attachmentDisplayUrlsByBasename.set(basename, dummyPhotoUrl); + + const draftFilePath = `/mock_document/attachments/draft/${basename}`; const mockCameraResult: CameraResult = { fieldId, @@ -644,9 +679,9 @@ class WebViewMock { data: { type: 'image', id: imageGuid, - filename: `${imageGuid}.jpg`, - uri: dummyPhotoUrl, // Use the dummy photo URL as the URI for display - url: dummyPhotoUrl, // For compatibility with CameraResultData.url + filename: basename, + uri: draftFilePath, + url: `file://${draftFilePath}`, timestamp: new Date().toISOString(), metadata: { width: 1920, @@ -836,9 +871,8 @@ class WebViewMock { private simulateFileSuccessResponse( fieldId: string, mimeType: string, - filename: string, + originalFilename: string, ): void { - // Generate GUID for file const generateGUID = () => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( /[xy]/g, @@ -850,24 +884,35 @@ class WebViewMock { ); }; - const fileGuid = generateGUID(); - const extension = filename.split('.').pop() || ''; - const mockFileSize = Math.floor(Math.random() * 1000000) + 50000; // 50KB to 1MB - const mockUri = `file:///storage/emulated/0/Android/data/com.formulus/files/${fileGuid}.${extension}`; + const extFromName = /\.([^.\\/]{1,32})$/.exec(originalFilename); + const subtype = + mimeType + ?.split('/')[1] + ?.split('+')[0] + ?.replace(/[^a-z0-9]/gi, '') ?? ''; + const fromName = extFromName?.[1]?.toLowerCase().trim() ?? ''; + const fromMime = + subtype.length > 0 && subtype.length <= 16 ? subtype.toLowerCase() : ''; + const ext = (fromName.length > 0 ? fromName : fromMime) || 'bin'; + + const basename = `${generateGUID()}.${ext}`; + const draftPath = `/mock_document/attachments/draft/${basename}`; + const mockFileSize = Math.floor(Math.random() * 1000000) + 50000; const mockFileResult: FileResult = { fieldId, status: 'success', data: { type: 'file', - filename, - uri: mockUri, + filename: basename, + uri: draftPath, + url: `file://${draftPath}`, mimeType, size: mockFileSize, timestamp: new Date().toISOString(), metadata: { - extension, - originalPath: `/storage/emulated/0/Download/${filename}`, + extension: ext, + originalFileName: originalFilename, }, }, }; @@ -877,7 +922,6 @@ class WebViewMock { mockFileResult, ); - // Resolve the pending Promise for this field const pendingPromise = this.pendingFilePromises.get(fieldId); if (pendingPromise) { pendingPromise.resolve(mockFileResult); @@ -1049,8 +1093,11 @@ class WebViewMock { ); // Generate mock audio file data - const mockFilename = `audio_${Date.now()}.m4a`; + const basename = `audio_${Date.now()}.m4a`; const dummyAudioUrl = `${window.location.origin}/dummyaudio.m4a`; + this.attachmentDisplayUrlsByBasename.set(basename, dummyAudioUrl); + + const draftPath = `/mock_document/attachments/draft/${basename}`; const base64Placeholder = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAESsAACJWAAACABAAZGF0YQAAAAA='; // tiny WAV header stub @@ -1059,9 +1106,10 @@ class WebViewMock { status: 'success', data: { type: 'audio', - filename: mockFilename, + filename: basename, + uri: draftPath, + url: `file://${draftPath}`, base64: base64Placeholder, - url: dummyAudioUrl, timestamp: new Date().toISOString(), metadata: { duration: 15.5, // 15.5 seconds @@ -1459,13 +1507,21 @@ class WebViewMock { fieldId, ); + const basename = `video_${Date.now()}.mp4`; + const demoVideoUrl = + 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4'; + this.attachmentDisplayUrlsByBasename.set(basename, demoVideoUrl); + + const draftPath = `/mock_document/attachments/draft/${basename}`; + const videoResult: VideoResult = { fieldId, status: 'success', data: { type: 'video', - filename: `video_${Date.now()}.mp4`, - uri: `file:///mock/videos/video_${Date.now()}.mp4`, + filename: basename, + uri: draftPath, + url: `file://${draftPath}`, timestamp: new Date().toISOString(), metadata: { duration: 15.5, // 15.5 seconds diff --git a/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx b/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx index 22606b25c..216bd13b4 100644 --- a/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/AudioQuestionRenderer.tsx @@ -1,6 +1,18 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { + useState, + useRef, + useEffect, + useCallback, + useMemo, +} from 'react'; import { withJsonFormsControlProps } from '@jsonforms/react'; -import { ControlProps, rankWith, formatIs } from '@jsonforms/core'; +import { + ControlProps, + rankWith, + schemaTypeIs, + and, + schemaMatches, +} from '@jsonforms/core'; import { Box, Typography, @@ -18,55 +30,117 @@ import { Refresh as RefreshIcon, } from '@mui/icons-material'; import FormulusClient from '../services/FormulusInterface'; -import { AudioResult } from '../types/FormulusInterfaceDefinition'; +import { + AudioResult, + AudioResultData, +} from '../types/FormulusInterfaceDefinition'; import QuestionShell from '../components/QuestionShell'; import { tokens } from '../theme/tokens-adapter'; - -// Helper to parse pixel values from tokens -const parsePx = (value: string): number => { - return parseInt(value.replace('px', ''), 10); -}; - -interface AudioQuestionRendererProps extends ControlProps { - data: any; - handleChange(path: string, value: any): void; - path: string; +import { + attachmentBasenameFromFilename, + attachmentBasenameFromObservation, +} from '../utils/attachmentBasename'; + +const parsePx = (value: string): number => + parseInt(value.replace('px', ''), 10); + +type AudioObservationMetadata = Pick< + AudioResultData['metadata'], + 'duration' | 'format' | 'size' | 'sampleRate' | 'channels' +>; + +function observationAudioMetadataFromBridge( + m: AudioResultData['metadata'], +): AudioObservationMetadata { + const out: AudioObservationMetadata = { + duration: m.duration, + format: m.format, + size: m.size, + }; + if (m.sampleRate != null) { + out.sampleRate = m.sampleRate; + } + if (m.channels != null) { + out.channels = m.channels; + } + return out; } -interface AudioData { - type: 'audio'; - filename: string; - uri: string; - timestamp: string; - metadata: { - duration: number; - format: string; - size: number; - }; +export const audioQuestionTester = rankWith( + 10, + and( + schemaTypeIs('object'), + schemaMatches(schema => schema.format === 'audio'), + ), +); + +function legacyPlayableUrl( + data: Record | null, +): string | null { + if (!data) { + return null; + } + const url = data.url; + if (typeof url === 'string' && /^https?:\/\//i.test(url.trim())) { + return url.trim(); + } + const uri = data.uri; + if (typeof uri === 'string') { + const u = uri.trim(); + if ( + /^https?:\/\//i.test(u) || + u.startsWith('blob:') || + u.startsWith('data:') + ) { + return u; + } + } + return null; } -const AudioQuestionRenderer: React.FC = ({ +const AudioQuestionRenderer: React.FC = ({ data, handleChange, path, schema, uischema, errors, + enabled = true, }) => { const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [mediaUrl, setMediaUrl] = useState(null); + + const setSafeError = useCallback((errorMessage: string | null) => { + if (errorMessage === null || errorMessage === undefined) { + setError(null); + } else if (typeof errorMessage === 'string' && errorMessage.length > 0) { + setError(errorMessage); + } else { + setError('An unknown error occurred'); + } + }, []); const audioRef = useRef(null); const progressInterval = useRef | null>(null); + const formulusClient = useRef(FormulusClient.getInstance()); + + const fieldId = path.replace(/\//g, '_').replace(/^_/, '') || 'audio_field'; - const audioData: AudioData | null = - data && typeof data === 'object' && data.type === 'audio' ? data : null; - const hasAudio = !!audioData; + const currentAudioData = useMemo(() => { + if ( + data && + typeof data === 'object' && + (data as { type?: string }).type === 'audio' + ) { + return data as Record; + } + return null; + }, [data]); - // Clean up intervals on unmount useEffect(() => { return () => { if (progressInterval.current) { @@ -75,10 +149,33 @@ const AudioQuestionRenderer: React.FC = ({ }; }, []); - // Handle audio element events + useEffect(() => { + let cancelled = false; + const run = async () => { + const base = attachmentBasenameFromObservation(currentAudioData); + let resolved: string | null = null; + if (base) { + const r = await formulusClient.current.getAttachmentUri(base); + resolved = r != null && r.trim() !== '' ? r.trim() : null; + } + if (!resolved) { + resolved = legacyPlayableUrl(currentAudioData); + } + if (!cancelled) { + setMediaUrl(resolved); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [currentAudioData]); + useEffect(() => { const audio = audioRef.current; - if (!audio) return; + if (!audio) { + return; + } const handleLoadedMetadata = () => { setDuration(audio.duration); @@ -97,60 +194,88 @@ const AudioQuestionRenderer: React.FC = ({ } }; - const handleError = () => { - setError('Failed to load audio file'); + const handleAudioError = () => { + setSafeError('Failed to load audio'); setIsPlaying(false); }; audio.addEventListener('loadedmetadata', handleLoadedMetadata); audio.addEventListener('timeupdate', handleTimeUpdate); audio.addEventListener('ended', handleEnded); - audio.addEventListener('error', handleError); + audio.addEventListener('error', handleAudioError); return () => { audio.removeEventListener('loadedmetadata', handleLoadedMetadata); audio.removeEventListener('timeupdate', handleTimeUpdate); audio.removeEventListener('ended', handleEnded); - audio.removeEventListener('error', handleError); + audio.removeEventListener('error', handleAudioError); }; - }, [audioData]); + }, [currentAudioData, mediaUrl, setSafeError]); - const handleRecord = async () => { - setError(null); + const handleRecord = useCallback(async () => { + if (!enabled) { + return; + } + setSafeError(null); setIsLoading(true); try { - const fieldId = path.replace(/\./g, '_'); - console.log('Requesting audio recording for field:', fieldId); - const result: AudioResult = - await FormulusClient.getInstance().requestAudio(fieldId); + await formulusClient.current.requestAudio(fieldId); if (result.status === 'success' && result.data) { - console.log('Audio recording successful:', result); - handleChange(path, result.data); + const storedBasename = attachmentBasenameFromFilename( + result.data.filename, + ); + if (!storedBasename) { + setSafeError('Invalid audio filename from recorder.'); + return; + } + + const audioPayload = { + type: 'audio' as const, + filename: storedBasename, + timestamp: result.data.timestamp, + metadata: observationAudioMetadataFromBridge(result.data.metadata), + }; + + handleChange(path, audioPayload); + setSafeError(null); } else if (result.status === 'cancelled') { - console.log('Audio recording cancelled'); - // Don't show error for cancellation + setSafeError(null); } else { - console.error('Audio recording failed:', result); - setError(result.message || 'Audio recording failed'); + setSafeError(result.message || 'Audio recording failed'); } - } catch (error: any) { - console.error('Audio recording error:', error); - if (error.status === 'cancelled') { - // Don't show error for cancellation + } catch (err: unknown) { + if ( + err && + typeof err === 'object' && + 'status' in err && + (err as AudioResult).status === 'cancelled' + ) { + setSafeError(null); } else { - setError(error.message || 'Failed to record audio'); + const msg = + err instanceof Error + ? err.message + : typeof err === 'object' && + err !== null && + 'message' in err && + typeof (err as { message?: string }).message === 'string' + ? (err as { message: string }).message + : 'Failed to record audio'; + setSafeError(msg); } } finally { setIsLoading(false); } - }; + }, [enabled, fieldId, handleChange, path, setSafeError]); const handlePlay = () => { const audio = audioRef.current; - if (!audio || !audioData) return; + if (!audio || !currentAudioData) { + return; + } if (isPlaying) { audio.pause(); @@ -164,21 +289,21 @@ const AudioQuestionRenderer: React.FC = ({ .play() .then(() => { setIsPlaying(true); - // Update progress more frequently for smoother UI progressInterval.current = setInterval(() => { setCurrentTime(audio.currentTime); }, 100); }) - .catch(error => { - console.error('Failed to play audio:', error); - setError('Failed to play audio'); + .catch(() => { + setSafeError('Failed to play audio'); }); } }; const handleStop = () => { const audio = audioRef.current; - if (!audio) return; + if (!audio) { + return; + } audio.pause(); audio.currentTime = 0; @@ -191,11 +316,12 @@ const AudioQuestionRenderer: React.FC = ({ }; const handleDelete = () => { - handleChange(path, null); + handleChange(path, undefined); setCurrentTime(0); setDuration(0); setIsPlaying(false); - setError(null); + setSafeError(null); + setMediaUrl(null); if (progressInterval.current) { clearInterval(progressInterval.current); progressInterval.current = null; @@ -209,37 +335,65 @@ const AudioQuestionRenderer: React.FC = ({ }; const getFileSizeString = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + const displayBasename = attachmentBasenameFromObservation(currentAudioData); + const validationError = errors && Array.isArray(errors) && errors.length > 0 - ? errors.map((error: any) => error.message || String(error)).join(', ') + ? errors + .map((e: { message?: string } | string) => + typeof e === 'object' && e?.message ? e.message : String(e), + ) + .join(', ') : null; + const meta = currentAudioData?.metadata as + | AudioObservationMetadata + | undefined; + + const label = + (uischema as { label?: string })?.label || schema.title || 'Audio'; + const description = schema.description; + const isRequired = Boolean( + (uischema as { options?: { required?: boolean } })?.options?.required ?? + (schema as { options?: { required?: boolean } })?.options?.required ?? + false, + ); + + const hasAudio = + !!displayBasename && + !!currentAudioData && + typeof meta?.duration === 'number'; + return ( + helperText={ + displayBasename + ? `File: ${displayBasename}` + : 'Record clear audio. You can re-record or delete as needed.' + }> {!hasAudio ? ( - // Recording State = ({ }}> = ({ )} ) : ( - // Playback State - {/* Audio element (hidden) */} - - {/* Control Buttons */} = ({ = ({ - {/* Action Buttons */} @@ -378,6 +533,7 @@ const AudioQuestionRenderer: React.FC = ({ @@ -385,7 +541,6 @@ const AudioQuestionRenderer: React.FC = ({ - {/* Development Info */} {process.env.NODE_ENV === 'development' && ( = ({ borderRadius: `${parsePx(tokens.border.radius.md)}px`, }}> - Dev Info: {audioData.uri} + Dev: mediaUrl={mediaUrl ?? 'null'} )} @@ -406,10 +561,4 @@ const AudioQuestionRenderer: React.FC = ({ ); }; -// Tester function to determine when this renderer should be used -export const audioQuestionTester = rankWith( - 10, // High priority - formatIs('audio'), -); - export default withJsonFormsControlProps(AudioQuestionRenderer); diff --git a/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx b/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx index 667db6d80..8e2ee5c1c 100644 --- a/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/FileQuestionRenderer.tsx @@ -1,19 +1,9 @@ import React, { useState, useCallback, useRef } from 'react'; -import { - Typography, - Box, - CircularProgress, - Paper, - IconButton, - Chip, -} from '@mui/material'; +import { Typography, Box, IconButton, CircularProgress } from '@mui/material'; import { AttachFile as FileIcon, Delete as DeleteIcon, InsertDriveFile as DocumentIcon, - Image as ImageIcon, - PictureAsPdf as PdfIcon, - Description as TextIcon, } from '@mui/icons-material'; import { withJsonFormsControlProps } from '@jsonforms/react'; import { @@ -24,22 +14,72 @@ import { schemaMatches, } from '@jsonforms/core'; import { tokens } from '../theme/tokens-adapter'; +import { + FileResult, + FileResultData, +} from '../types/FormulusInterfaceDefinition'; +import QuestionShell from '../components/QuestionShell'; +import { + attachmentBasenameFromFilename, + attachmentBasenameFromObservation, +} from '../utils/attachmentBasename'; +import FormulusClient from '../services/FormulusInterface'; + +const parsePx = (value: string): number => + parseInt(value.replace('px', ''), 10); -// Helper to parse pixel values from tokens -const parsePx = (value: string): number => { - return parseInt(value.replace('px', ''), 10); +/** Portable metadata stored on the observation (no host paths). */ +type FileObservationMetadata = Pick & { + mimeType: string; + size: number; + originalFileName?: string; }; -import FormulusClient from '../services/FormulusInterface'; -import { FileResult } from '../types/FormulusInterfaceDefinition'; -import QuestionShell from '../components/QuestionShell'; +function observationFileMetadataFromBridge( + d: FileResultData, +): FileObservationMetadata { + const out: FileObservationMetadata = { + mimeType: d.mimeType, + size: d.size, + extension: d.metadata.extension, + }; + if ( + typeof d.metadata.originalFileName === 'string' && + d.metadata.originalFileName.trim().length > 0 + ) { + out.originalFileName = d.metadata.originalFileName.trim(); + } + return out; +} + +function fileObservationRecord(data: unknown): Record | null { + if (!data || typeof data !== 'object') { + return null; + } + const o = data as Record; + return o.type === 'file' ? o : null; +} + +function displayFilenameForFileObservation( + obs: Record | null, +): string { + const basename = attachmentBasenameFromObservation(obs); + const meta = obs?.metadata as Record | undefined; + const original = + meta && typeof meta.originalFileName === 'string' + ? meta.originalFileName.trim() + : ''; + if (original.length > 0) { + return original; + } + return basename ?? ''; +} -// Tester function - determines when this renderer should be used export const fileQuestionTester = rankWith( - 5, // Priority (higher = more specific) + 5, and( - schemaTypeIs('object'), // Expects object data type - schemaMatches(schema => schema.format === 'select_file'), // Matches format + schemaTypeIs('object'), + schemaMatches(schema => schema.format === 'select_file'), ), ); @@ -53,119 +93,115 @@ const FileQuestionRenderer: React.FC = ({ enabled = true, visible = true, }) => { - // State management const [isSelecting, setIsSelecting] = useState(false); const [error, setError] = useState(null); - // Refs const formulusClient = useRef(FormulusClient.getInstance()); - // Extract field ID from path - const fieldId = path.split('.').pop() || path; + const fieldId = path.replace(/\//g, '_').replace(/^_/, '') || 'file_field'; + + const setSafeError = useCallback((errorMessage: string | null) => { + if (errorMessage === null || errorMessage === undefined) { + setError(null); + } else if (typeof errorMessage === 'string' && errorMessage.length > 0) { + setError(errorMessage); + } else { + setError('An unknown error occurred'); + } + }, []); - // Handle file selection via React Native const handleFileSelection = useCallback(async () => { + if (!enabled) return; + setIsSelecting(true); - setError(null); + setSafeError(null); try { const result: FileResult = await formulusClient.current.requestFile(fieldId); if (result.status === 'success' && result.data) { - // Update form data with the file result - handleChange(path, result.data); + const storedBasename = attachmentBasenameFromFilename( + result.data.filename, + ); + if (!storedBasename) { + setSafeError('Invalid file name from picker.'); + return; + } + + const portable = { + type: 'file' as const, + filename: storedBasename, + timestamp: result.data.timestamp, + ...observationFileMetadataFromBridge(result.data), + }; + + handleChange(path, portable); + setSafeError(null); + } else { + const errorMessage = + result.message || `File selection ${result.status}`; + throw new Error(errorMessage); } - } catch (err: any) { - if (err.status === 'cancelled') { - // User cancelled - don't show error - console.log('File selection cancelled by user'); - } else if (err.status === 'error') { - setError(err.message || 'File selection failed'); + } catch (err: unknown) { + console.error('Error during file request:', err); + + if (err && typeof err === 'object' && 'status' in err) { + const fr = err as FileResult; + if (fr.status === 'cancelled') { + setSafeError(null); + } else if (fr.status === 'error') { + setSafeError(fr.message || 'File selection failed'); + } else { + setSafeError('Unknown file selection error'); + } } else { - setError('An unexpected error occurred'); + const msg = + err instanceof Error + ? err.message + : typeof err === 'string' + ? err + : 'Failed to select file.'; + setSafeError(msg); } } finally { setIsSelecting(false); } - }, [fieldId, handleChange, path]); + }, [enabled, fieldId, handleChange, path, setSafeError]); - // Handle delete/clear const handleDelete = useCallback(() => { - handleChange(path, null); - setError(null); - }, [handleChange, path]); + handleChange(path, undefined); + setSafeError(null); + }, [handleChange, path, setSafeError]); - // Get file icon based on MIME type - using tokens for colors - const getFileIcon = (mimeType: string) => { - if (mimeType.startsWith('image/')) { - return ; - } else if (mimeType === 'application/pdf') { - return ; - } else if ( - mimeType.startsWith('text/') || - mimeType.includes('document') || - mimeType.includes('spreadsheet') || - mimeType.includes('presentation') - ) { - return ; - } else { - return ; - } - }; - - // Format file size - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - }; - - // Get file type label - const getFileTypeLabel = (mimeType: string): string => { - if (mimeType.startsWith('image/')) { - return 'Image'; - } else if (mimeType === 'application/pdf') { - return 'PDF'; - } else if (mimeType.includes('document')) { - return 'Document'; - } else if (mimeType.includes('spreadsheet')) { - return 'Spreadsheet'; - } else if (mimeType.includes('presentation')) { - return 'Presentation'; - } else if (mimeType.startsWith('text/')) { - return 'Text'; - } else { - return 'File'; - } - }; - - // Don't render if not visible if (!visible) { return null; } - const hasData = data && typeof data === 'object' && data.type === 'file'; - const hasError = - errors && (Array.isArray(errors) ? errors.length > 0 : errors.length > 0); - const validationError = hasError - ? Array.isArray(errors) - ? errors.join(', ') - : (errors as any) - : null; + const obs = fileObservationRecord(data); + const hasData = obs !== null; + const displayName = displayFilenameForFileObservation(obs); + const validationError = + errors && errors.length > 0 ? String(errors[0]) : null; + + const label = (uischema as { label?: string }).label ?? schema.title; + const description = schema.description; + const isRequired = Boolean( + (uischema as { options?: { required?: boolean } }).options?.required ?? + (schema as { options?: { required?: boolean } }).options?.required, + ); return ( = ({ borderRadius: `${parsePx(tokens.border.radius.md)}px`, }}> - Debug: fieldId="{fieldId}", path="{path}", format="select_file" + Debug: fieldId="{fieldId}", path="{path}", + format="select_file" ) : undefined }> - {/* File Selection Button */} {!hasData && ( = ({ px: 2, }}> void handleFileSelection()} disabled={!enabled || isSelecting} color="primary" size="large" @@ -202,9 +238,7 @@ const FileQuestionRenderer: React.FC = ({ height: { xs: 56, sm: 64 }, backgroundColor: 'primary.main', color: 'white', - '&:hover': { - backgroundColor: 'primary.dark', - }, + '&:hover': { backgroundColor: 'primary.dark' }, '&:disabled': { backgroundColor: 'action.disabledBackground', color: 'action.disabled', @@ -221,78 +255,59 @@ const FileQuestionRenderer: React.FC = ({ variant="body2" color="text.secondary" sx={{ mt: 2, textAlign: 'center' }}> - {isSelecting ? 'Selecting file...' : 'Tap to select file'} + {isSelecting ? 'Selecting file…' : 'Tap to select file'} )} - {/* File Display */} {hasData && ( - - + + - - - {getFileIcon(data.mimeType)} - - - {data.filename} - - - - - {data.metadata.extension && ( - - )} - - - - - - - - - - - - - - - + flex: 1, + minWidth: 0, + fontWeight: 500, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }} + title={displayName || undefined}> + {displayName || 'Attached file'} + + void handleFileSelection()} + disabled={!enabled || isSelecting} + color="primary" + size="small" + aria-label="Replace file"> + {isSelecting ? ( + + ) : ( + + )} + + + + + )} ); diff --git a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx index e479dc825..6b5d5d2cc 100644 --- a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx +++ b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx @@ -65,7 +65,13 @@ const FinalizeRenderer = ({ data }: ControlProps) => { return 'Signature provided'; case 'select_file': if (typeof value === 'object' && value.filename) { - return `File: ${value.filename}`; + const original = + typeof value.metadata?.originalFileName === 'string' + ? value.metadata.originalFileName.trim() + : ''; + const label = + original.length > 0 ? original : String(value.filename); + return `File: ${label}`; } return 'File selected'; case 'audio': diff --git a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx index b8f0ff83c..a403da608 100644 --- a/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/PhotoQuestionRenderer.tsx @@ -23,34 +23,16 @@ import { } from '../types/FormulusInterfaceDefinition'; import QuestionShell from '../components/QuestionShell'; import { tokens } from '../theme/tokens-adapter'; +import { + attachmentBasenameFromFilename, + attachmentBasenameFromObservation, +} from '../utils/attachmentBasename'; // Helper to parse pixel values from tokens const parsePx = (value: string): number => { return parseInt(value.replace('px', ''), 10); }; -/** - * Basename for {@link FormulusClient.getAttachmentUri} from `photo.filename` - * (handles values that mistakenly include path segments). - */ -function photoAttachmentBasename( - data: Record | null, -): string | null { - if (!data || typeof data.filename !== 'string') { - return null; - } - const t = data.filename.trim(); - if (!t) { - return null; - } - const normalized = t.replace(/\\/g, '/'); - const last = normalized.split('/').pop()?.trim() ?? ''; - if (!last || last === '.' || last === '..' || last.includes('..')) { - return null; - } - return last; -} - /** * Subset of camera metadata kept on the observation (portable, no host paths or picker noise). */ @@ -71,27 +53,6 @@ function observationPhotoMetadataFromCamera( }; } -/** Never pass another host's `file://` path to `` (e.g. stale bridge output or legacy data). */ -function webviewSafeImageSrc(url: string | null): string | null { - if (url == null || url === '') { - return null; - } - const u = url.trim(); - if (!u.startsWith('file://')) { - return u; - } - const lower = u.toLowerCase(); - if ( - lower.includes('/data/user/') || - lower.includes('org.opendataensemble.formulus') || - lower.includes('/var/mobile/') || - lower.includes('/application/') - ) { - return null; - } - return u; -} - // Tester function to identify photo question types export const photoQuestionTester = rankWith( 5, // High priority for photo questions @@ -143,19 +104,21 @@ const PhotoQuestionRenderer: React.FC = ({ // Get the current photo data from the form data (now JSON format) const currentPhotoData = data || null; - // Always resolve by basename so WebView never loads another device's file:// path. + // Previews always come from the bridge — same contract as production (`resolveAttachmentDisplayUri` on RN). useEffect(() => { let cancelled = false; const run = async () => { console.log('Photo data changed:', currentPhotoData); - const base = photoAttachmentBasename( + const base = attachmentBasenameFromObservation( currentPhotoData as Record | null, ); const resolved = await formulusClient.current.getAttachmentUri( base ?? null, ); if (!cancelled) { - setPhotoUrl(webviewSafeImageSrc(resolved)); + const trimmed = + resolved != null && resolved.trim() !== '' ? resolved.trim() : null; + setPhotoUrl(trimmed); console.log('Resolved photo display URL:', resolved); } }; @@ -183,12 +146,19 @@ const PhotoQuestionRenderer: React.FC = ({ // Check if the result was successful if (cameraResult.status === 'success' && cameraResult.data) { - // Persist portable fields only — basename is stable across devices; avoid - // storing host-specific file paths in observation JSON. + const storedBasename = attachmentBasenameFromFilename( + cameraResult.data.filename, + ); + if (!storedBasename) { + setSafeError('Invalid photo filename from camera.'); + return; + } + + // Persist portable fields only — basename only; never persist bridge uri/url. const photoData = { id: cameraResult.data.id, type: cameraResult.data.type, - filename: cameraResult.data.filename, + filename: storedBasename, timestamp: cameraResult.data.timestamp, metadata: observationPhotoMetadataFromCamera( cameraResult.data.metadata, @@ -200,20 +170,9 @@ const PhotoQuestionRenderer: React.FC = ({ size: photoData.metadata.size, }); - // Update the form data with the photo data console.log('Updating form data with photo data...'); handleChange(path, photoData); - const resolved = await formulusClient.current.getAttachmentUri( - photoData.filename, - ); - setPhotoUrl(webviewSafeImageSrc(resolved)); - console.log( - 'Setting photo URL for display via getAttachmentUri:', - resolved, - ); - - // Clear any previous errors on successful photo capture console.log('Clearing error state after successful photo capture'); setSafeError(null); @@ -276,6 +235,10 @@ const PhotoQuestionRenderer: React.FC = ({ const validationError = errors && errors.length > 0 ? String(errors[0]) : null; + const displayBasename = attachmentBasenameFromObservation( + currentPhotoData as Record | null, + ); + return ( = ({ required={isRequired} error={error || validationError} helperText={ - currentPhotoData?.filename - ? `File: ${currentPhotoData.filename}` - : 'Capture a clear photo.' + displayBasename ? `File: ${displayBasename}` : 'Capture a clear photo.' } metadata={ process.env.NODE_ENV === 'development' ? ( @@ -313,14 +274,11 @@ const PhotoQuestionRenderer: React.FC = ({ path, currentPhotoData, hasPhotoData: !!currentPhotoData, - hasFilename: !!currentPhotoData?.filename, + displayBasename, + hasDisplayBasename: !!displayBasename, photoUrl, hasPhotoUrl: !!photoUrl, - shouldShowThumbnail: !!( - currentPhotoData && - currentPhotoData.filename && - photoUrl - ), + shouldShowThumbnail: !!(displayBasename && photoUrl), isLoading, error, }, @@ -331,7 +289,7 @@ const PhotoQuestionRenderer: React.FC = ({ ) : undefined }> - {currentPhotoData && currentPhotoData.filename && photoUrl ? ( + {displayBasename && photoUrl ? ( = ({ variant="body2" color="text.secondary" sx={{ flex: 1, mr: 1 }}> - {currentPhotoData.filename} + {displayBasename} { - return parseInt(value.replace('px', ''), 10); -}; import { Videocam as VideocamIcon, PlayArrow as PlayIcon, @@ -27,63 +33,208 @@ import { VideoFile as VideoFileIcon, } from '@mui/icons-material'; import QuestionShell from '../components/QuestionShell'; -// Note: The shared Formulus interface v1.1.0 no longer exposes a -// requestVideo() API. This renderer therefore does not actively -// trigger native video recording anymore. It can still display -// existing video metadata if present in the form data. - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface VideoQuestionRendererProps extends ControlProps { - // Additional props if needed -} +import FormulusClient from '../services/FormulusInterface'; +import { + VideoResult, + VideoResultData, +} from '../types/FormulusInterfaceDefinition'; +import { + attachmentBasenameFromFilename, + attachmentBasenameFromObservation, +} from '../utils/attachmentBasename'; + +const parsePx = (value: string): number => + parseInt(value.replace('px', ''), 10); + +type VideoObservationMetadata = Pick< + VideoResultData['metadata'], + 'duration' | 'format' | 'size' | 'width' | 'height' +>; -interface VideoDisplayData { - filename: string; - uri: string; - timestamp: string; - metadata: { - duration: number; - format: string; - size: number; - width?: number; - height?: number; +function observationVideoMetadataFromBridge( + m: VideoResultData['metadata'], +): VideoObservationMetadata { + const out: VideoObservationMetadata = { + duration: m.duration, + format: m.format, + size: m.size, }; + if (m.width != null) { + out.width = m.width; + } + if (m.height != null) { + out.height = m.height; + } + return out; } -const VideoQuestionRenderer: React.FC = props => { - const { data, handleChange, path, errors, schema, enabled } = props; +export const videoQuestionTester = rankWith( + 10, + and( + schemaTypeIs('object'), + schemaMatches(schema => schema.format === 'video'), + ), +); - const [videoData, setVideoData] = useState(null); +function legacyVideoPlayableUrl( + data: Record | null, +): string | null { + if (!data) { + return null; + } + const url = data.url; + if (typeof url === 'string' && /^https?:\/\//i.test(url.trim())) { + return url.trim(); + } + const uri = data.uri; + if (typeof uri === 'string') { + const u = uri.trim(); + if ( + /^https?:\/\//i.test(u) || + u.startsWith('blob:') || + u.startsWith('data:') + ) { + return u; + } + } + return null; +} + +const VideoQuestionRenderer: React.FC = ({ + data, + handleChange, + path, + errors, + schema, + uischema, + enabled = true, +}) => { const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [mediaUrl, setMediaUrl] = useState(null); const videoElementRef = useRef(null); + const formulusClient = useRef(FormulusClient.getInstance()); - // Parse existing video data if present - useEffect(() => { - if (data && typeof data === 'string') { + const setSafeError = useCallback((errorMessage: string | null) => { + if (errorMessage === null || errorMessage === undefined) { + setError(null); + } else if (typeof errorMessage === 'string' && errorMessage.length > 0) { + setError(errorMessage); + } else { + setError('An unknown error occurred'); + } + }, []); + + const fieldId = path.replace(/\//g, '_').replace(/^_/, '') || 'video_field'; + + const currentVideoData = useMemo(() => { + if (data && typeof data === 'object') { + const r = data as Record; + if (r.type === 'video') { + return r; + } + } + if (typeof data === 'string') { try { - const parsed = JSON.parse(data); - if (parsed && parsed.filename && parsed.uri) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setVideoData(parsed); + const parsed = JSON.parse(data) as unknown; + if ( + parsed && + typeof parsed === 'object' && + (parsed as { type?: string }).type === 'video' + ) { + return parsed as Record; } - } catch (e) { - console.warn('Failed to parse existing video data:', e); + } catch { + /* legacy string payloads ignored */ } } + return null; }, [data]); - const handleRecordVideo = async () => { - // The current Formulus interface version does not provide a - // requestVideo() API. Surface a clear message so users are not - // confused when pressing the button. - setError('Video recording is not supported in this version of the app.'); - }; + useEffect(() => { + let cancelled = false; + const run = async () => { + const base = attachmentBasenameFromObservation(currentVideoData); + let resolved: string | null = null; + if (base) { + const r = await formulusClient.current.getAttachmentUri(base); + resolved = r != null && r.trim() !== '' ? r.trim() : null; + } + if (!resolved) { + resolved = legacyVideoPlayableUrl(currentVideoData); + } + if (!cancelled) { + setMediaUrl(resolved); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [currentVideoData]); + + const handleRecordVideo = useCallback(async () => { + if (!enabled) { + return; + } + setSafeError(null); + setIsLoading(true); + + try { + const result: VideoResult = + await formulusClient.current.requestVideo(fieldId); + + if (result.status === 'success' && result.data) { + const storedBasename = attachmentBasenameFromFilename( + result.data.filename, + ); + if (!storedBasename) { + setSafeError('Invalid video filename from recorder.'); + return; + } + + handleChange(path, { + type: 'video' as const, + filename: storedBasename, + timestamp: result.data.timestamp, + metadata: observationVideoMetadataFromBridge(result.data.metadata), + }); + setSafeError(null); + } else if (result.status === 'cancelled') { + setSafeError(null); + } else { + setSafeError(result.message || 'Video recording failed'); + } + } catch (err: unknown) { + if ( + err && + typeof err === 'object' && + 'status' in err && + (err as VideoResult).status === 'cancelled' + ) { + setSafeError(null); + } else { + const msg = + err instanceof Error + ? err.message + : typeof err === 'object' && + err !== null && + 'message' in err && + typeof (err as { message?: string }).message === 'string' + ? (err as { message: string }).message + : 'Failed to record video'; + setSafeError(msg); + } + } finally { + setIsLoading(false); + } + }, [enabled, fieldId, handleChange, path, setSafeError]); const handleDeleteVideo = () => { - setVideoData(null); - setError(null); + setSafeError(null); setIsPlaying(false); + setMediaUrl(null); if (videoElementRef.current) { videoElementRef.current.pause(); videoElementRef.current.currentTime = 0; @@ -92,31 +243,42 @@ const VideoQuestionRenderer: React.FC = props => { }; const handlePlayPause = () => { - if (!videoElementRef.current || !videoData) return; + const el = videoElementRef.current; + if (!el || !currentVideoData) { + return; + } if (isPlaying) { - videoElementRef.current.pause(); + el.pause(); setIsPlaying(false); } else { - videoElementRef.current.play(); - setIsPlaying(true); + void el + .play() + .then(() => setIsPlaying(true)) + .catch(() => { + setSafeError('Failed to play video'); + }); } }; const handleStop = () => { - if (!videoElementRef.current) return; - - videoElementRef.current.pause(); - videoElementRef.current.currentTime = 0; + const el = videoElementRef.current; + if (!el) { + return; + } + el.pause(); + el.currentTime = 0; setIsPlaying(false); }; const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 Bytes'; + if (bytes === 0) { + return '0 Bytes'; + } const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; }; const formatDuration = (seconds: number): string => { @@ -133,28 +295,52 @@ const VideoQuestionRenderer: React.FC = props => { } }; + const displayBasename = attachmentBasenameFromObservation(currentVideoData); + + const meta = currentVideoData?.metadata as + | VideoObservationMetadata + | undefined; + const hasValidationErrors = errors && errors.length > 0; - const isDisabled = !enabled; + const validationError = hasValidationErrors + ? Array.isArray(errors) + ? errors + .map((e: { message?: string } | string) => + typeof e === 'object' && e && 'message' in e && e.message + ? String(e.message) + : String(e), + ) + .join(', ') + : String(errors) + : null; + + const label = + (uischema as { label?: string })?.label || + schema.title || + 'Video Recording'; + const description = schema.description; + const isRequired = Boolean( + (uischema as { options?: { required?: boolean } })?.options?.required ?? + (schema as { options?: { required?: boolean } })?.options?.required ?? + false, + ); + + const hasVideo = + !!displayBasename && + !!currentVideoData && + typeof meta?.duration === 'number'; return ( error.message || String(error)) - .join(', ') - : errors - : null) + title={label} + description={description} + required={isRequired} + error={error || validationError} + helperText={ + displayBasename + ? `File: ${displayBasename}` + : 'Capture a video if required.' } - helperText="Capture a video if required. Current app version may not support recording." metadata={ process.env.NODE_ENV === 'development' ? ( = props => { borderRadius: `${parsePx(tokens.border.radius.md)}px`, }}> - Debug - Path: {path} | Data: {JSON.stringify(data)} + Debug — path: {path} | mediaUrl: {mediaUrl ?? 'null'} ) : undefined }> - {/* Video Display or Record Button */} - {videoData ? ( + {hasVideo ? ( - Video Recorded + Video recorded } @@ -189,36 +374,29 @@ const VideoQuestionRenderer: React.FC = props => { - {/* Video Player */} - {/* Video Controls */} @@ -226,68 +404,73 @@ const VideoQuestionRenderer: React.FC = props => { - - + + Filename - {videoData.filename} + {displayBasename} - + - + Duration - {formatDuration(videoData.metadata.duration)} + {formatDuration(meta?.duration ?? 0)} - + - + - File Size + File size - {formatFileSize(videoData.metadata.size)} + {meta?.size != null ? formatFileSize(meta.size) : '—'} - + - {videoData.metadata.width && videoData.metadata.height && ( - + {meta?.width != null && meta?.height != null && ( + Resolution - {videoData.metadata.width} × {videoData.metadata.height} + {meta.width} × {meta.height} - + )} + - - - - Recorded at: {formatTimestamp(videoData.timestamp)} - - - + + + Recorded at:{' '} + {typeof currentVideoData?.timestamp === 'string' + ? formatTimestamp(currentVideoData.timestamp) + : '—'} + - {/* Action Buttons */} @@ -295,7 +478,7 @@ const VideoQuestionRenderer: React.FC = props => { @@ -316,7 +499,7 @@ const VideoQuestionRenderer: React.FC = props => { }}> = props => { variant="body2" color="text.secondary" sx={{ mt: 2, textAlign: 'center' }}> - Tap to record video + {isLoading ? 'Opening camera...' : 'Tap to record video'} )} @@ -347,10 +530,4 @@ const VideoQuestionRenderer: React.FC = props => { ); }; -// Tester function to determine when this renderer should be used -export const videoQuestionTester = rankWith( - 10, // Priority - higher than default string renderer - formatIs('video'), -); - export default withJsonFormsControlProps(VideoQuestionRenderer); diff --git a/formulus-formplayer/src/services/FormulusInterface.ts b/formulus-formplayer/src/services/FormulusInterface.ts index 0b0037f56..c6548361b 100644 --- a/formulus-formplayer/src/services/FormulusInterface.ts +++ b/formulus-formplayer/src/services/FormulusInterface.ts @@ -11,6 +11,7 @@ import { AttachmentDisplayDescriptor, FormulusInterface, CameraResult, + VideoResult, QrcodeResult, FileResult, AudioResult, @@ -69,6 +70,19 @@ class FormulusClient { return FormulusClient.instance; } + /** + * Drop the cached injected API so the next bridge call re-runs `window.getFormulus()`. + * Storybook installs a different partial {@link FormulusInterface} per story; without this, + * navigating from e.g. Photo to File leaves a stale object missing `requestFile`. + */ + public static clearCachedFormulusApi(): void { + FormulusClient.instance?.resetCachedFormulusApi(); + } + + private resetCachedFormulusApi(): void { + this.formulus = null; + } + /** * Submit form data with proper create/update logic based on context * @param formInitData - The form initialization data containing observationId and formType @@ -199,6 +213,23 @@ class FormulusClient { }); } + /** + * Request video recording from the Formulus RN app + */ + public async requestVideo(fieldId: string): Promise { + console.debug('Requesting video for field', fieldId); + await this.tryEnsureFormulus(); + if (this.formulus) { + return this.formulus.requestVideo(fieldId); + } + console.warn('Formulus interface not available for requestVideo'); + return Promise.reject({ + fieldId, + status: 'error', + message: 'Formulus interface not available', + }); + } + /** * Launch an Android intent from the Formulus RN app */ diff --git a/formulus-formplayer/src/stories/AudioQuestionRenderer.stories.tsx b/formulus-formplayer/src/stories/AudioQuestionRenderer.stories.tsx new file mode 100644 index 000000000..c4b2d6c96 --- /dev/null +++ b/formulus-formplayer/src/stories/AudioQuestionRenderer.stories.tsx @@ -0,0 +1,202 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import AudioQuestionRenderer, { + audioQuestionTester, +} from '../renderers/AudioQuestionRenderer'; +import type { + AudioResult, + FormulusInterface, +} from '../types/FormulusInterfaceDefinition'; +import { JsonFormsControlWrapper } from './JsonFormsControlWrapper'; + +/** Short CC0 sample — playable in iframe without bundling binary assets. */ +const DEMO_AUDIO_URL = + 'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3'; + +const audioSchema = { + type: 'object', + properties: { + audioField: { + type: 'object', + format: 'audio', + title: 'Voice note', + description: 'Record a short audio clip.', + }, + }, +}; + +const audioUischema = { + type: 'Control', + scope: '#/properties/audioField', +}; + +const renderers = [ + { tester: audioQuestionTester, renderer: AudioQuestionRenderer }, + ...materialRenderers, +]; + +function basenameFromAttachmentRef( + fileRef: string | { filename?: string }, +): string { + const raw = + typeof fileRef === 'string' ? fileRef : (fileRef.filename ?? '').trim(); + if (!raw) { + return ''; + } + const normalized = raw.replace(/\\/g, '/'); + return normalized.split('/').pop()?.trim() ?? ''; +} + +function storyAudioBasename(fixed?: string): string { + if (fixed) { + return fixed; + } + const id = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : String(Date.now()); + return `story-audio-${id}.m4a`; +} + +function createAudioStoryFormulusMock(options: { + seedBasenames?: Record; + captureBasename?: string; + previewUrlAfterCapture?: string; +}): FormulusInterface { + const vault = new Map( + Object.entries(options.seedBasenames ?? {}), + ); + const previewUrl = options.previewUrlAfterCapture ?? DEMO_AUDIO_URL; + + const iface = { + requestAudio: async (_fieldId: string): Promise => { + const basename = storyAudioBasename(options.captureBasename); + vault.set(basename, previewUrl); + const ts = new Date().toISOString(); + const draftPath = `/story_mock/attachments/draft/${basename}`; + return { + status: 'success', + data: { + type: 'audio', + filename: basename, + uri: `file://${draftPath}`, + url: previewUrl, + base64: '', + timestamp: ts, + metadata: { + duration: 2.5, + format: 'm4a', + sampleRate: 44100, + channels: 1, + size: 120000, + }, + }, + }; + }, + getAttachmentUri: async ( + fileRef: string | { filename?: string }, + ): Promise => { + const base = basenameFromAttachmentRef(fileRef); + return base ? (vault.get(base) ?? null) : null; + }, + }; + + return iface as FormulusInterface; +} + +function installGetFormulusMock(iface: FormulusInterface): void { + ( + window as unknown as { getFormulus: () => Promise } + ).getFormulus = () => Promise.resolve(iface); +} + +type StoryProps = React.ComponentProps; + +function AudioStoryDecorator( + Story: React.ComponentType, + context: { parameters: { formulusMock?: FormulusInterface } }, +) { + const mock = + context.parameters.formulusMock ?? + createAudioStoryFormulusMock({ + seedBasenames: { + 'existing-audio.m4a': DEMO_AUDIO_URL, + }, + }); + installGetFormulusMock(mock); + return ; +} + +const meta: Meta = { + title: 'Question Renderers/AudioQuestionRenderer', + component: JsonFormsControlWrapper, + decorators: [AudioStoryDecorator], + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { + schema: audioSchema, + uischema: audioUischema, + initialData: {}, + renderers, + }, +}; + +export const WithExistingAudio: Story = { + args: { + schema: audioSchema, + uischema: audioUischema, + initialData: { + audioField: { + type: 'audio', + filename: 'existing-audio.m4a', + timestamp: new Date().toISOString(), + metadata: { + duration: 3, + format: 'm4a', + sampleRate: 44100, + channels: 1, + size: 95000, + }, + }, + }, + renderers, + }, +}; + +export const AttachmentUriUnavailable: Story = { + parameters: { + formulusMock: createAudioStoryFormulusMock({ + seedBasenames: {}, + captureBasename: 'missing-uri.m4a', + }), + }, + args: { + schema: audioSchema, + uischema: audioUischema, + initialData: { + audioField: { + type: 'audio', + filename: 'missing-uri.m4a', + timestamp: new Date().toISOString(), + metadata: { + duration: 1, + format: 'm4a', + sampleRate: 44100, + channels: 1, + size: 1000, + }, + }, + }, + renderers, + }, +}; diff --git a/formulus-formplayer/src/stories/FileQuestionRenderer.stories.tsx b/formulus-formplayer/src/stories/FileQuestionRenderer.stories.tsx new file mode 100644 index 000000000..2cc83b1d3 --- /dev/null +++ b/formulus-formplayer/src/stories/FileQuestionRenderer.stories.tsx @@ -0,0 +1,175 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import FileQuestionRenderer, { + fileQuestionTester, +} from '../renderers/FileQuestionRenderer'; +import type { + FileResult, + FormulusInterface, +} from '../types/FormulusInterfaceDefinition'; +import { JsonFormsControlWrapper } from './JsonFormsControlWrapper'; + +const fileSchema = { + type: 'object', + properties: { + fileField: { + type: 'object', + format: 'select_file', + title: 'Attachment', + description: + 'Select a generic file. Stored like other attachments (basename + metadata only).', + }, + }, +}; + +const fileUischema = { + type: 'Control', + scope: '#/properties/fileField', +}; + +const renderers = [ + { tester: fileQuestionTester, renderer: FileQuestionRenderer }, + ...materialRenderers, +]; + +function storyFileBasename(fixed?: string): string { + if (fixed) { + return fixed; + } + const id = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : String(Date.now()); + return `story-file-${id}.pdf`; +} + +/** + * Storybook bridge: {@link FormulusInterface.requestFile} returns basename + portable metadata + * like RN after copying into attachments draft. + */ +function createFileStoryFormulusMock(options: { + captureBasename?: string; + /** Display name shown from metadata.originalFileName */ + pickerDisplayName?: string; +}): FormulusInterface { + const pickerDisplayName = options.pickerDisplayName ?? 'Quarterly-report.pdf'; + + const iface = { + requestFile: async (_fieldId: string): Promise => { + const basename = storyFileBasename(options.captureBasename); + const ts = new Date().toISOString(); + const draftPath = `/story_mock/attachments/draft/${basename}`; + return { + status: 'success', + data: { + type: 'file', + filename: basename, + uri: draftPath, + url: `file://${draftPath}`, + mimeType: 'application/pdf', + size: 128_000, + timestamp: ts, + metadata: { + extension: basename.includes('.') + ? basename.split('.').pop()!.toLowerCase() + : 'pdf', + originalFileName: pickerDisplayName, + }, + }, + }; + }, + getAttachmentUri: async (): Promise => null, + }; + + return iface as FormulusInterface; +} + +function installGetFormulusMock(iface: FormulusInterface): void { + ( + window as unknown as { getFormulus: () => Promise } + ).getFormulus = () => Promise.resolve(iface); +} + +type StoryProps = React.ComponentProps; + +function FileStoryDecorator( + Story: React.ComponentType, + context: { parameters: { formulusMock?: FormulusInterface } }, +) { + const mock = + context.parameters.formulusMock ?? createFileStoryFormulusMock({}); + installGetFormulusMock(mock); + return ; +} + +const meta: Meta = { + title: 'Question Renderers/FileQuestionRenderer', + component: JsonFormsControlWrapper, + decorators: [FileStoryDecorator], + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + initialData: { + description: + 'Initial form data under `fileField` (object with basename `filename`)', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { + schema: fileSchema, + uischema: fileUischema, + initialData: {}, + renderers, + }, +}; + +export const WithExistingFile: Story = { + args: { + schema: fileSchema, + uischema: fileUischema, + initialData: { + fileField: { + type: 'file', + filename: 'a1b2c3d4-eeee-4fff-aaaa-bbbbbbbbbbbb.pdf', + timestamp: new Date().toISOString(), + metadata: { + mimeType: 'application/pdf', + size: 99000, + extension: 'pdf', + originalFileName: 'Signed waiver.pdf', + }, + }, + }, + renderers, + }, +}; + +/** Older observations may only have attachment basename — UI falls back to `filename`. */ +export const BasenameOnlyLegacy: Story = { + args: { + schema: fileSchema, + uischema: fileUischema, + initialData: { + fileField: { + type: 'file', + filename: 'legacy-key-from-sync.bin', + timestamp: new Date().toISOString(), + metadata: { + mimeType: 'application/octet-stream', + size: 1024, + extension: 'bin', + }, + }, + }, + renderers, + }, +}; diff --git a/formulus-formplayer/src/stories/PhotoQuestionRenderer.stories.tsx b/formulus-formplayer/src/stories/PhotoQuestionRenderer.stories.tsx new file mode 100644 index 000000000..923703576 --- /dev/null +++ b/formulus-formplayer/src/stories/PhotoQuestionRenderer.stories.tsx @@ -0,0 +1,220 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import PhotoQuestionRenderer, { + photoQuestionTester, +} from '../renderers/PhotoQuestionRenderer'; +import type { + CameraResult, + FormulusInterface, +} from '../types/FormulusInterfaceDefinition'; +import { JsonFormsControlWrapper } from './JsonFormsControlWrapper'; + +import demoPhotoUrl from './assets/sig.png'; + +const photoSchema = { + type: 'object', + properties: { + photoField: { + type: 'object', + format: 'photo', + title: 'Site photo', + description: 'Capture a photo for this observation.', + }, + }, +}; + +const photoUischema = { + type: 'Control', + scope: '#/properties/photoField', +}; + +const renderers = [ + { tester: photoQuestionTester, renderer: PhotoQuestionRenderer }, + ...materialRenderers, +]; + +function basenameFromAttachmentRef( + fileRef: string | { filename?: string }, +): string { + const raw = + typeof fileRef === 'string' ? fileRef : (fileRef.filename ?? '').trim(); + if (!raw) { + return ''; + } + const normalized = raw.replace(/\\/g, '/'); + return normalized.split('/').pop()?.trim() ?? ''; +} + +function storyCaptureBasename(fixed?: string): string { + if (fixed) { + return fixed; + } + const id = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : String(Date.now()); + return `story-capture-${id}.jpg`; +} + +/** + * Storybook bridge aligned with RN: {@link requestCamera} registers a basename → display URL; + * previews load only through {@link FormulusInterface.getAttachmentUri} (like + * {@link resolveAttachmentDisplayUri} on device). + */ +function createPhotoStoryFormulusMock(options: { + /** Pre-seed basenames (e.g. loaded observation) → browser-loadable preview URL */ + seedBasenames?: Record; + /** Fixed basename for each simulated capture (default: random per tap) */ + captureBasename?: string; + /** URL returned by getAttachmentUri after capture */ + previewUrlAfterCapture?: string; +}): FormulusInterface { + const vault = new Map( + Object.entries(options.seedBasenames ?? {}), + ); + const previewUrl = options.previewUrlAfterCapture ?? demoPhotoUrl; + + const iface = { + requestCamera: async (_fieldId: string): Promise => { + const basename = storyCaptureBasename(options.captureBasename); + vault.set(basename, previewUrl); + const ts = new Date().toISOString(); + const draftPath = `/story_mock/attachments/draft/${basename}`; + return { + status: 'success', + data: { + type: 'image', + id: basename.replace(/\.jpg$/i, ''), + filename: basename, + uri: draftPath, + url: `file://${draftPath}`, + timestamp: ts, + metadata: { + width: 1200, + height: 900, + size: 95000, + mimeType: 'image/jpeg', + source: 'storybook_mock', + quality: 85, + persistentStorage: true, + storageLocation: 'story_attachments_draft', + }, + }, + }; + }, + getAttachmentUri: async ( + fileRef: string | { filename?: string }, + ): Promise => { + const base = basenameFromAttachmentRef(fileRef); + return base ? (vault.get(base) ?? null) : null; + }, + }; + + return iface as FormulusInterface; +} + +function installGetFormulusMock(iface: FormulusInterface): void { + ( + window as unknown as { getFormulus: () => Promise } + ).getFormulus = () => Promise.resolve(iface); +} + +type StoryProps = React.ComponentProps; + +function PhotoStoryDecorator( + Story: React.ComponentType, + context: { parameters: { formulusMock?: FormulusInterface } }, +) { + const mock = + context.parameters.formulusMock ?? + createPhotoStoryFormulusMock({ + seedBasenames: { + 'existing-photo.jpg': demoPhotoUrl, + }, + }); + installGetFormulusMock(mock); + return ; +} + +const meta: Meta = { + title: 'Question Renderers/PhotoQuestionRenderer', + component: JsonFormsControlWrapper, + decorators: [PhotoStoryDecorator], + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + initialData: { + description: + 'Initial form data under `photoField` (object with basename `filename` after capture)', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { + schema: photoSchema, + uischema: photoUischema, + initialData: {}, + renderers, + }, +}; + +export const WithExistingPhoto: Story = { + args: { + schema: photoSchema, + uischema: photoUischema, + initialData: { + photoField: { + id: 'existing-id', + type: 'image', + filename: 'existing-photo.jpg', + timestamp: new Date().toISOString(), + metadata: { + width: 1200, + height: 900, + size: 95000, + mimeType: 'image/jpeg', + quality: 85, + }, + }, + }, + renderers, + }, +}; + +/** Same contract as RN when the file is missing from draft/synced folders — basename ok, no preview URL. */ +export const AttachmentUriUnavailable: Story = { + parameters: { + formulusMock: createPhotoStoryFormulusMock({ + seedBasenames: {}, + captureBasename: 'missing-uri.jpg', + }), + }, + args: { + schema: photoSchema, + uischema: photoUischema, + initialData: { + photoField: { + id: 'no-uri', + type: 'image', + filename: 'missing-uri.jpg', + timestamp: new Date().toISOString(), + metadata: { + width: 800, + height: 600, + size: 1000, + mimeType: 'image/jpeg', + quality: 80, + }, + }, + }, + renderers, + }, +}; diff --git a/formulus-formplayer/src/stories/SubObservationQuestionRenderer.stories.tsx b/formulus-formplayer/src/stories/SubObservationQuestionRenderer.stories.tsx new file mode 100644 index 000000000..f65386c71 --- /dev/null +++ b/formulus-formplayer/src/stories/SubObservationQuestionRenderer.stories.tsx @@ -0,0 +1,283 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React, { useEffect } from 'react'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import { + SubObservationQuestionRenderer, + subObservationQuestionTester, +} from '../renderers/SubObservationQuestionRenderer'; +import type { + FormCompletionResult, + FormulusInterface, +} from '../types/FormulusInterfaceDefinition'; +import { JsonFormsControlWrapper } from './JsonFormsControlWrapper'; + +const linkedChildFormType = 'story_child_visit'; + +const parentSchemaBase = { + type: 'object', + properties: { + observationId: { + type: 'string', + title: 'Parent observation ID', + default: 'story-parent-obs-001', + }, + sites: { + type: 'array', + format: 'sub-observation', + title: 'Site visits', + description: + 'Embedded repeats; Storybook mocks nested sessions via openFormplayer(subObservationMode).', + linkedForm: linkedChildFormType, + parentKey: 'parentSurveyId', + parentValuePath: 'observationId', + columns: [ + { key: 'siteName', label: 'Site' }, + { key: 'visitDate', label: 'Visited' }, + ], + items: { type: 'object' }, + }, + }, +}; + +const parentUischema = { + type: 'VerticalLayout', + elements: [ + { type: 'Control', scope: '#/properties/observationId' }, + { + type: 'Control', + scope: '#/properties/sites', + label: 'Related site visits', + }, + ], +}; + +const renderers = [ + { + tester: subObservationQuestionTester, + renderer: SubObservationQuestionRenderer, + }, + ...materialRenderers, +]; + +type AddBehaviour = 'submit_new_row' | 'cancel_add'; + +function createSubObservationStoryFormulusMock(options?: { + addBehaviour?: AddBehaviour; +}): FormulusInterface { + const addBehaviour = options?.addBehaviour ?? 'submit_new_row'; + + const iface: Pick = { + openFormplayer: async ( + formType: string, + params: Record, + savedData: Record, + opts?: { subObservationMode?: boolean }, + ): Promise => { + if (!opts?.subObservationMode) { + return { + status: 'error', + formType, + message: + 'Storybook mock: openFormplayer only implements subObservationMode', + }; + } + + const savedKeys = Object.keys(savedData || {}).filter( + k => savedData[k] !== undefined && savedData[k] !== null, + ); + const isEdit = savedKeys.length > 0; + + if (!isEdit && addBehaviour === 'cancel_add') { + return { status: 'cancelled', formType }; + } + + const parentLink = + (typeof params.parentSurveyId === 'string' && params.parentSurveyId) || + (typeof savedData.parentSurveyId === 'string' && + savedData.parentSurveyId) || + ''; + + if (!isEdit) { + return { + status: 'form_submitted', + formType, + formData: { + siteName: 'Mock added site', + visitDate: '2026-05-04', + parentSurveyId: parentLink || 'story-parent-fallback', + }, + }; + } + + const prevName = + typeof savedData.siteName === 'string' ? savedData.siteName : 'Site'; + return { + status: 'form_updated', + formType, + formData: { + ...savedData, + siteName: `${prevName} (edited)`, + }, + }; + }, + }; + + return iface as FormulusInterface; +} + +function installGetFormulusMock(iface: FormulusInterface): void { + ( + window as unknown as { getFormulus: () => Promise } + ).getFormulus = () => Promise.resolve(iface); +} + +type StoryProps = React.ComponentProps; + +function ConfirmStubWrapper({ children }: { children: React.ReactNode }) { + useEffect(() => { + const prev = window.confirm; + window.confirm = () => true; + return () => { + window.confirm = prev; + }; + }, []); + return <>{children}; +} + +function SubObservationStoryDecorator( + Story: React.ComponentType, + context: { parameters: { formulusMock?: FormulusInterface } }, +) { + const mock = + context.parameters.formulusMock ?? + createSubObservationStoryFormulusMock({}); + installGetFormulusMock(mock); + return ( + + + + ); +} + +const meta: Meta = { + title: 'Question Renderers/SubObservationQuestionRenderer', + component: JsonFormsControlWrapper, + decorators: [SubObservationStoryDecorator], + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { + schema: parentSchemaBase, + uischema: parentUischema, + initialData: { + observationId: 'story-parent-obs-001', + sites: [], + }, + renderers, + }, +}; + +export const WithRows: Story = { + args: { + schema: parentSchemaBase, + uischema: parentUischema, + initialData: { + observationId: 'story-parent-obs-001', + sites: [ + { + siteName: 'North station', + visitDate: '2026-01-10', + parentSurveyId: 'story-parent-obs-001', + }, + { + siteName: 'South depot', + visitDate: '2026-02-02', + parentSurveyId: 'story-parent-obs-001', + }, + ], + }, + renderers, + }, +}; + +/** Schema missing `linkedForm` — renderer surfaces configuration error. */ +export const Misconfigured: Story = { + args: { + schema: { + type: 'object', + properties: { + observationId: { + type: 'string', + title: 'Parent observation ID', + default: 'x', + }, + sites: { + type: 'array', + format: 'sub-observation', + title: 'Broken repeat', + parentKey: 'parentSurveyId', + items: { type: 'object' }, + }, + }, + }, + uischema: parentUischema, + initialData: { + observationId: 'x', + sites: [], + }, + renderers, + }, +}; + +/** Simulated user backs out of the nested form — no row appended. */ +export const AddCancelled: Story = { + parameters: { + formulusMock: createSubObservationStoryFormulusMock({ + addBehaviour: 'cancel_add', + }), + }, + args: { + schema: parentSchemaBase, + uischema: parentUischema, + initialData: { + observationId: 'story-parent-obs-001', + sites: [], + }, + renderers, + }, +}; + +export const DeleteDisabled: Story = { + args: { + schema: { + ...parentSchemaBase, + properties: { + ...parentSchemaBase.properties, + sites: { + ...parentSchemaBase.properties.sites, + allowDelete: false, + }, + }, + }, + uischema: parentUischema, + initialData: { + observationId: 'story-parent-obs-001', + sites: [ + { + siteName: 'Locked row', + visitDate: '2026-03-01', + parentSurveyId: 'story-parent-obs-001', + }, + ], + }, + renderers, + }, +}; diff --git a/formulus-formplayer/src/stories/VideoQuestionRenderer.stories.tsx b/formulus-formplayer/src/stories/VideoQuestionRenderer.stories.tsx new file mode 100644 index 000000000..521a56501 --- /dev/null +++ b/formulus-formplayer/src/stories/VideoQuestionRenderer.stories.tsx @@ -0,0 +1,200 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import VideoQuestionRenderer, { + videoQuestionTester, +} from '../renderers/VideoQuestionRenderer'; +import type { + FormulusInterface, + VideoResult, +} from '../types/FormulusInterfaceDefinition'; +import { JsonFormsControlWrapper } from './JsonFormsControlWrapper'; + +const DEMO_VIDEO_URL = + 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4'; + +const videoSchema = { + type: 'object', + properties: { + videoField: { + type: 'object', + format: 'video', + title: 'Video clip', + description: 'Record a short video.', + }, + }, +}; + +const videoUischema = { + type: 'Control', + scope: '#/properties/videoField', +}; + +const renderers = [ + { tester: videoQuestionTester, renderer: VideoQuestionRenderer }, + ...materialRenderers, +]; + +function basenameFromAttachmentRef( + fileRef: string | { filename?: string }, +): string { + const raw = + typeof fileRef === 'string' ? fileRef : (fileRef.filename ?? '').trim(); + if (!raw) { + return ''; + } + const normalized = raw.replace(/\\/g, '/'); + return normalized.split('/').pop()?.trim() ?? ''; +} + +function storyVideoBasename(fixed?: string): string { + if (fixed) { + return fixed; + } + const id = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID() + : String(Date.now()); + return `story-video-${id}.mp4`; +} + +function createVideoStoryFormulusMock(options: { + seedBasenames?: Record; + captureBasename?: string; + previewUrlAfterCapture?: string; +}): FormulusInterface { + const vault = new Map( + Object.entries(options.seedBasenames ?? {}), + ); + const previewUrl = options.previewUrlAfterCapture ?? DEMO_VIDEO_URL; + + const iface = { + requestVideo: async (_fieldId: string): Promise => { + const basename = storyVideoBasename(options.captureBasename); + vault.set(basename, previewUrl); + const ts = new Date().toISOString(); + const draftPath = `/story_mock/attachments/draft/${basename}`; + return { + status: 'success', + data: { + type: 'video', + filename: basename, + uri: draftPath, + url: `file://${draftPath}`, + timestamp: ts, + metadata: { + duration: 12, + format: 'mp4', + size: 500000, + width: 1280, + height: 720, + }, + }, + }; + }, + getAttachmentUri: async ( + fileRef: string | { filename?: string }, + ): Promise => { + const base = basenameFromAttachmentRef(fileRef); + return base ? (vault.get(base) ?? null) : null; + }, + }; + + return iface as FormulusInterface; +} + +function installGetFormulusMock(iface: FormulusInterface): void { + ( + window as unknown as { getFormulus: () => Promise } + ).getFormulus = () => Promise.resolve(iface); +} + +type StoryProps = React.ComponentProps; + +function VideoStoryDecorator( + Story: React.ComponentType, + context: { parameters: { formulusMock?: FormulusInterface } }, +) { + const mock = + context.parameters.formulusMock ?? + createVideoStoryFormulusMock({ + seedBasenames: { + 'existing-video.mp4': DEMO_VIDEO_URL, + }, + }); + installGetFormulusMock(mock); + return ; +} + +const meta: Meta = { + title: 'Question Renderers/VideoQuestionRenderer', + component: JsonFormsControlWrapper, + decorators: [VideoStoryDecorator], + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { + schema: videoSchema, + uischema: videoUischema, + initialData: {}, + renderers, + }, +}; + +export const WithExistingVideo: Story = { + args: { + schema: videoSchema, + uischema: videoUischema, + initialData: { + videoField: { + type: 'video', + filename: 'existing-video.mp4', + timestamp: new Date().toISOString(), + metadata: { + duration: 10, + format: 'mp4', + size: 400000, + width: 1280, + height: 720, + }, + }, + }, + renderers, + }, +}; + +export const AttachmentUriUnavailable: Story = { + parameters: { + formulusMock: createVideoStoryFormulusMock({ + seedBasenames: {}, + captureBasename: 'missing-uri.mp4', + }), + }, + args: { + schema: videoSchema, + uischema: videoUischema, + initialData: { + videoField: { + type: 'video', + filename: 'missing-uri.mp4', + timestamp: new Date().toISOString(), + metadata: { + duration: 5, + format: 'mp4', + size: 200000, + width: 640, + height: 480, + }, + }, + }, + renderers, + }, +}; diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index 32bf85e13..e5208f54b 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -80,11 +80,16 @@ export interface ActionResult { } /** - * Camera-specific result data - * @property {'image'} type - Always 'image' for camera results - * @property {string} filename - Generated filename for the image - * @property {string} timestamp - ISO timestamp when image was captured - * @property {object} metadata - Image metadata (dimensions, size, etc.) + * Camera-specific result data (immediate bridge payload after capture). + * + * **Observation JSON:** persist only portable fields — {@link id}, {@link type}, + * {@link filename} (basename only, e.g. `.jpg`), {@link timestamp}, and {@link metadata}. + * Do **not** persist {@link uri} or {@link url}; resolve thumbnails and playback URLs with + * {@link FormulusInterface.getAttachmentUri}. + * + * @property {string} filename - Stable attachment basename, not a directory path. + * @property {string} uri - Native absolute filesystem path on the host (debugging / RN use); ephemeral. + * @property {string} url - Transient `file://` (or similar) for the same file as {@link uri}; ephemeral. */ export interface CameraResultData { type: 'image'; @@ -115,26 +120,51 @@ export interface AttachmentDisplayDescriptor { } /** - * Audio-specific result data - * @property {'audio'} type - Always 'audio' for audio results - * @property {string} filename - Generated filename for the audio - * @property {string} base64 - Base64 encoded audio data - * @property {string} url - Data URL for the audio - * @property {string} timestamp - ISO timestamp when audio was recorded - * @property {object} metadata - Audio metadata (duration, format, etc.) + * Audio-specific bridge payload after recording. + * + * **Observation JSON:** persist only **`type`**, **`filename`** (basename), + * **`timestamp`**, and **`metadata`** (portable subset). Do **not** persist **`uri`**, + * **`url`**, or **`base64`**; resolve playback URLs with {@link FormulusInterface.getAttachmentUri}. + * + * @property {string} uri - Native path or `file://` URL — ephemeral. + * @property {string} [base64] - Optional (e.g. browser mocks). + * @property {string} [url] - Optional data/http URL — ephemeral. */ export interface AudioResultData { type: 'audio'; filename: string; - base64: string; - url: string; + uri?: string; + base64?: string; + url?: string; timestamp: string; metadata: { duration: number; format: string; - sampleRate: number; - channels: number; size: number; + sampleRate?: number; + channels?: number; + }; +} + +/** + * Video-specific bridge payload after recording. + * + * **Observation JSON:** persist only **`type`**, **`filename`** (basename), + * **`timestamp`**, and **`metadata`**. Do **not** persist **`uri`** / **`url`**; + * resolve playback URLs with {@link FormulusInterface.getAttachmentUri}. + */ +export interface VideoResultData { + type: 'video'; + filename: string; + uri?: string; + url?: string; + timestamp: string; + metadata: { + duration: number; + format: string; + size: number; + width?: number; + height?: number; }; } @@ -151,24 +181,32 @@ export interface QrcodeResultData { } /** - * File selection result data - * @property {'file'} type - Always 'file' for file selection results - * @property {string} filename - Original filename of the selected file - * @property {string} uri - Local file URI (no base64 encoding) - * @property {string} mimeType - MIME type of the selected file - * @property {number} size - File size in bytes - * @property {string} timestamp - ISO timestamp when file was selected - * @property {object} metadata - File metadata (extension, original path, etc.) + * File selection result data from the native document picker (bridge payload). + * + * **Observation persistence:** store {@link FileResultData.filename} as **basename only** + * (draft/synced attachment key), plus portable {@link FileResultData.metadata} fields such as + * `mimeType`, `size`, `extension`, and optional **{@link FileResultData.metadata.originalFileName}** + * for display. Do **not** persist bridge-only paths: {@link FileResultData.uri}, + * {@link FileResultData.url}, or {@link FileResultData.metadata.originalPath}. + * + * @property {'file'} type - Always `file` for file selection results + * @property {string} filename - Stable basename under attachments (e.g. uuid.pdf) + * @property {string} uri - Ephemeral absolute path of the draft copy (native → WebView) + * @property {string} [url] - Ephemeral `file://` URL for the draft copy */ export interface FileResultData { type: 'file'; filename: string; - uri: string; // Local file URI (no base64 encoding) + uri: string; + url?: string; mimeType: string; size: number; timestamp: string; metadata: { extension: string; + /** Original picker display name; safe to persist on observations. */ + originalFileName?: string; + /** Ephemeral native path from the picker; do not persist. */ originalPath?: string; }; } @@ -191,6 +229,7 @@ export interface LocationResultData { */ export type CameraResult = ActionResult; export type AudioResult = ActionResult; +export type VideoResult = ActionResult; export type QrcodeResult = ActionResult; export type FileResult = ActionResult; export type LocationResult = ActionResult; @@ -418,6 +457,13 @@ export interface FormulusInterface { */ requestAudio(fieldId: string): Promise; + /** + * Request video recording for a field (camera / picker — host-defined). + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with video result or rejects on error/cancellation + */ + requestVideo(fieldId: string): Promise; + /** * Request QR code scanning for a field * @param {string} fieldId - The ID of the field diff --git a/formulus-formplayer/src/utils/attachmentBasename.ts b/formulus-formplayer/src/utils/attachmentBasename.ts new file mode 100644 index 000000000..66ec61d1e --- /dev/null +++ b/formulus-formplayer/src/utils/attachmentBasename.ts @@ -0,0 +1,27 @@ +/** + * Basename for {@link FormulusClient.getAttachmentUri} from observation `filename` + * (handles values that mistakenly include path segments). Rejects traversal. + */ +export function attachmentBasenameFromObservation( + data: Record | null, +): string | null { + if (!data || typeof data.filename !== 'string') { + return null; + } + return attachmentBasenameFromFilename(data.filename); +} + +export function attachmentBasenameFromFilename( + filename: string, +): string | null { + const t = filename.trim(); + if (!t) { + return null; + } + const normalized = t.replace(/\\/g, '/'); + const last = normalized.split('/').pop()?.trim() ?? ''; + if (!last || last === '.' || last === '..' || last.includes('..')) { + return null; + } + return last; +} diff --git a/formulus/AGENTS.md b/formulus/AGENTS.md index 23143151d..1d226c1ca 100644 --- a/formulus/AGENTS.md +++ b/formulus/AGENTS.md @@ -22,7 +22,7 @@ | `src/webview/` | **Bridge contract** — [`FormulusInterfaceDefinition.ts`](src/webview/FormulusInterfaceDefinition.ts) (source of truth for `window.formulus` / injected API). [`FormulusMessageHandlers.ts`](src/webview/FormulusMessageHandlers.ts), [`FormulusWebViewHandler.ts`](src/webview/FormulusWebViewHandler.ts). | | `scripts/generateInjectionScript.ts` | Generates injection / loader script from the interface definition. | | `src/screens/`, `src/navigation/` | App screens and routing. | -| Android / iOS | Native projects; **formplayer** static assets: `android/app/src/main/assets/formplayer_dist/`, `ios/formplayer_dist/` (see formplayer AGENTS for `build:rn`). | +| Android / iOS | Native projects; **formplayer** static assets: `android/app/src/main/assets/formplayer_dist/`, `ios/formplayer_dist/` (see formplayer AGENTS for `build:copy`). | --- diff --git a/formulus/assets/webview/FormulusInjectionScript.js b/formulus/assets/webview/FormulusInjectionScript.js index 9db92fa56..16a7b340f 100644 --- a/formulus/assets/webview/FormulusInjectionScript.js +++ b/formulus/assets/webview/FormulusInjectionScript.js @@ -1,6 +1,6 @@ // Auto-generated from FormulusInterfaceDefinition.ts // Do not edit directly - this file will be overwritten -// Last generated: 2026-05-02T16:53:14.030Z +// Last generated: 2026-05-04T10:47:58.554Z (function () { // Enhanced API availability detection and recovery @@ -911,6 +911,67 @@ }); }, + // requestVideo: fieldId: string => Promise + requestVideo: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestVideo callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestVideo callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestVideo_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'requestVideo' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'requestVideo', + messageId, + fieldId: fieldId, + }), + ); + }); + }, + // requestQrcode: fieldId: string => Promise requestQrcode: function (fieldId) { return new Promise((resolve, reject) => { diff --git a/formulus/assets/webview/formulus-api.js b/formulus/assets/webview/formulus-api.js index 8a6eb63db..35adf1230 100644 --- a/formulus/assets/webview/formulus-api.js +++ b/formulus/assets/webview/formulus-api.js @@ -5,7 +5,7 @@ * that's available in the WebView context as `globalThis.formulus`. * * This file is auto-generated from FormulusInterfaceDefinition.ts - * Last generated: 2026-05-02T16:53:14.307Z + * Last generated: 2026-05-04T10:47:58.847Z * * @example * // In your JavaScript file: @@ -139,6 +139,14 @@ const FormulusAPI = { */ requestAudio: function (fieldId) {}, + /** + * Request video recording for a field (camera / picker — host-defined). + * / + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with video result or rejects on error/cancellation + */ + requestVideo: function (fieldId) {}, + /** * Request QR code scanning for a field * / diff --git a/formulus/formplayer_question_types.md b/formulus/formplayer_question_types.md index 228beddae..df1f02cca 100644 --- a/formulus/formplayer_question_types.md +++ b/formulus/formplayer_question_types.md @@ -184,16 +184,16 @@ Signatures are stored as objects containing: ### 4. File Selection Question -The File Selection question type allows users to select files from their device using native file picker dialogs. This question type is designed to handle file URIs efficiently without base64 encoding, making it suitable for large files. +The File Selection question type allows users to pick files from device storage via the native document picker. On Formulus, the picked file is copied into **`attachments/draft/`** with a stable basename; observation JSON stores **basename + portable metadata** (same attachment model as photos). -**Schema Definition:** +**Schema Definition:** JSON Forms in Formplayer expects **`type: object`** with **`format: select_file`** on the property schema. ```json { "type": "object", "properties": { "documentUpload": { - "type": "string", + "type": "object", "format": "select_file", "title": "Upload Document", "description": "Select a document to upload" @@ -202,46 +202,36 @@ The File Selection question type allows users to select files from their device } ``` -**UI Schema:** - -```json -{ - "documentUpload": { - "ui:widget": "file", - "ui:options": { - "accept": "*/*", - "multiple": false - } - } -} -``` +**UI Schema:** JSON Forms — use a **Control** whose `scope` points at the property (see [ODE form specifications](https://opendataensemble.org/docs/reference/form-specifications)). **Features:** -- Native File Picker: Uses platform-specific file selection dialogs -- URI-Based Storage: Files are stored as URIs, not base64 encoded data -- File Metadata: Captures filename, size, MIME type, and timestamp -- Error Handling: Supports cancellation and error states -- File Preview: Shows selected file information with replace/delete options -- Mock Support: Interactive simulation for development testing +- Native file picker (`@react-native-documents/picker`) +- Attachment storage under app **`attachments/`** (draft copy with generated basename) +- Observation JSON: **`filename`** (basename only), **`timestamp`**, **`metadata`** (`mimeType`, `size`, `extension`, optional **`originalFileName`** for display) +- UI shows **filename only** (no inline preview); replace / delete supported +- Error handling: cancellation vs errors +- Mock support in formplayer dev (`webview-mock`) -**Data Structure:** -When a file is selected, the field value becomes a structured object: +**Persisted field value (example):** ```json { - "filename": "document.pdf", - "uri": "file:///path/to/cached/document.pdf", - "size": 1024000, - "mimeType": "application/pdf", "type": "file", - "timestamp": "2024-01-15T10:30:00.000Z" + "filename": "a1b2c3d4-eeee-4fff-aaaa-bbbbbbbbbbbb.pdf", + "timestamp": "2024-01-15T10:30:00.000Z", + "metadata": { + "mimeType": "application/pdf", + "size": 1024000, + "extension": "pdf", + "originalFileName": "Quarterly-report.pdf" + } } ``` **Dependencies:** -- `react-native-document-picker`: For native file selection functionality +- `@react-native-documents/picker`: Native document picker used by Formulus `onRequestFile` ### 5. Audio Recording Question diff --git a/formulus/src/webview/FormulusInterfaceDefinition.ts b/formulus/src/webview/FormulusInterfaceDefinition.ts index 32bf85e13..e5208f54b 100644 --- a/formulus/src/webview/FormulusInterfaceDefinition.ts +++ b/formulus/src/webview/FormulusInterfaceDefinition.ts @@ -80,11 +80,16 @@ export interface ActionResult { } /** - * Camera-specific result data - * @property {'image'} type - Always 'image' for camera results - * @property {string} filename - Generated filename for the image - * @property {string} timestamp - ISO timestamp when image was captured - * @property {object} metadata - Image metadata (dimensions, size, etc.) + * Camera-specific result data (immediate bridge payload after capture). + * + * **Observation JSON:** persist only portable fields — {@link id}, {@link type}, + * {@link filename} (basename only, e.g. `.jpg`), {@link timestamp}, and {@link metadata}. + * Do **not** persist {@link uri} or {@link url}; resolve thumbnails and playback URLs with + * {@link FormulusInterface.getAttachmentUri}. + * + * @property {string} filename - Stable attachment basename, not a directory path. + * @property {string} uri - Native absolute filesystem path on the host (debugging / RN use); ephemeral. + * @property {string} url - Transient `file://` (or similar) for the same file as {@link uri}; ephemeral. */ export interface CameraResultData { type: 'image'; @@ -115,26 +120,51 @@ export interface AttachmentDisplayDescriptor { } /** - * Audio-specific result data - * @property {'audio'} type - Always 'audio' for audio results - * @property {string} filename - Generated filename for the audio - * @property {string} base64 - Base64 encoded audio data - * @property {string} url - Data URL for the audio - * @property {string} timestamp - ISO timestamp when audio was recorded - * @property {object} metadata - Audio metadata (duration, format, etc.) + * Audio-specific bridge payload after recording. + * + * **Observation JSON:** persist only **`type`**, **`filename`** (basename), + * **`timestamp`**, and **`metadata`** (portable subset). Do **not** persist **`uri`**, + * **`url`**, or **`base64`**; resolve playback URLs with {@link FormulusInterface.getAttachmentUri}. + * + * @property {string} uri - Native path or `file://` URL — ephemeral. + * @property {string} [base64] - Optional (e.g. browser mocks). + * @property {string} [url] - Optional data/http URL — ephemeral. */ export interface AudioResultData { type: 'audio'; filename: string; - base64: string; - url: string; + uri?: string; + base64?: string; + url?: string; timestamp: string; metadata: { duration: number; format: string; - sampleRate: number; - channels: number; size: number; + sampleRate?: number; + channels?: number; + }; +} + +/** + * Video-specific bridge payload after recording. + * + * **Observation JSON:** persist only **`type`**, **`filename`** (basename), + * **`timestamp`**, and **`metadata`**. Do **not** persist **`uri`** / **`url`**; + * resolve playback URLs with {@link FormulusInterface.getAttachmentUri}. + */ +export interface VideoResultData { + type: 'video'; + filename: string; + uri?: string; + url?: string; + timestamp: string; + metadata: { + duration: number; + format: string; + size: number; + width?: number; + height?: number; }; } @@ -151,24 +181,32 @@ export interface QrcodeResultData { } /** - * File selection result data - * @property {'file'} type - Always 'file' for file selection results - * @property {string} filename - Original filename of the selected file - * @property {string} uri - Local file URI (no base64 encoding) - * @property {string} mimeType - MIME type of the selected file - * @property {number} size - File size in bytes - * @property {string} timestamp - ISO timestamp when file was selected - * @property {object} metadata - File metadata (extension, original path, etc.) + * File selection result data from the native document picker (bridge payload). + * + * **Observation persistence:** store {@link FileResultData.filename} as **basename only** + * (draft/synced attachment key), plus portable {@link FileResultData.metadata} fields such as + * `mimeType`, `size`, `extension`, and optional **{@link FileResultData.metadata.originalFileName}** + * for display. Do **not** persist bridge-only paths: {@link FileResultData.uri}, + * {@link FileResultData.url}, or {@link FileResultData.metadata.originalPath}. + * + * @property {'file'} type - Always `file` for file selection results + * @property {string} filename - Stable basename under attachments (e.g. uuid.pdf) + * @property {string} uri - Ephemeral absolute path of the draft copy (native → WebView) + * @property {string} [url] - Ephemeral `file://` URL for the draft copy */ export interface FileResultData { type: 'file'; filename: string; - uri: string; // Local file URI (no base64 encoding) + uri: string; + url?: string; mimeType: string; size: number; timestamp: string; metadata: { extension: string; + /** Original picker display name; safe to persist on observations. */ + originalFileName?: string; + /** Ephemeral native path from the picker; do not persist. */ originalPath?: string; }; } @@ -191,6 +229,7 @@ export interface LocationResultData { */ export type CameraResult = ActionResult; export type AudioResult = ActionResult; +export type VideoResult = ActionResult; export type QrcodeResult = ActionResult; export type FileResult = ActionResult; export type LocationResult = ActionResult; @@ -418,6 +457,13 @@ export interface FormulusInterface { */ requestAudio(fieldId: string): Promise; + /** + * Request video recording for a field (camera / picker — host-defined). + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with video result or rejects on error/cancellation + */ + requestVideo(fieldId: string): Promise; + /** * Request QR code scanning for a field * @param {string} fieldId - The ID of the field diff --git a/formulus/src/webview/FormulusMessageHandlers.ts b/formulus/src/webview/FormulusMessageHandlers.ts index e1645d1fa..fd16ba9d1 100644 --- a/formulus/src/webview/FormulusMessageHandlers.ts +++ b/formulus/src/webview/FormulusMessageHandlers.ts @@ -710,36 +710,55 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { const asset = response.assets[0]; try { - // Generate a unique filename - const timestamp = Date.now(); - const filename = `video_${timestamp}.${ - asset.type?.split('/')[1] || 'mp4' - }`; + const generateGUID = () => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + function (c) { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }, + ); + }; - // Copy video to app storage directory - const destinationPath = `${RNFS.DocumentDirectoryPath}/videos/${filename}`; + const ext = asset.type?.split('/')[1] || 'mp4'; + const videoGuid = generateGUID(); + const filename = `${videoGuid}.${ext}`; - // Ensure videos directory exists - await RNFS.mkdir(`${RNFS.DocumentDirectoryPath}/videos`); + const attachmentsDirectory = `${RNFS.DocumentDirectoryPath}/attachments`; + const draftDirectory = `${attachmentsDirectory}/draft`; - // Copy the video file - if (asset.uri) { - await RNFS.copyFile(asset.uri, destinationPath); - } else { + await RNFS.mkdir(attachmentsDirectory); + await RNFS.mkdir(draftDirectory); + + const draftFilePath = `${draftDirectory}/${filename}`; + + if (!asset.uri) { console.error('Asset uri not available', asset); + reject({ + fieldId, + status: 'error', + message: 'Video asset URI not available', + }); + return; } + await RNFS.copyFile(asset.uri, draftFilePath); + + const webViewUrl = `file://${draftFilePath}`; + const videoResult = { fieldId, status: 'success' as const, data: { type: 'video' as const, filename, - uri: `file://${destinationPath}`, + uri: draftFilePath, + url: webViewUrl, timestamp: new Date().toISOString(), metadata: { duration: asset.duration || 0, - format: asset.type?.split('/')[1] || 'mp4', + format: ext, size: asset.fileSize || 0, width: asset.width, height: asset.height, @@ -789,16 +808,63 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { console.log('File selected:', result); + const originalName = + typeof result.name === 'string' && result.name.trim().length > 0 + ? result.name.trim() + : 'file'; + + const extFromName = /\.([^.\\/]{1,32})$/.exec(originalName); + const subtype = + result.type + ?.split('/')[1] + ?.split('+')[0] + ?.replace(/[^a-z0-9]/gi, '') ?? ''; + const fromName = extFromName?.[1]?.toLowerCase().trim() ?? ''; + const fromMime = + subtype.length > 0 && subtype.length <= 16 + ? subtype.toLowerCase() + : ''; + const ext = (fromName.length > 0 ? fromName : fromMime) || 'bin'; + + const generateGUID = () => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + function (c) { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }, + ); + }; + + const basename = `${generateGUID()}.${ext}`; + + const attachmentsDirectory = `${RNFS.DocumentDirectoryPath}/attachments`; + const draftDirectory = `${attachmentsDirectory}/draft`; + const draftFilePath = `${draftDirectory}/${basename}`; + + await RNFS.mkdir(attachmentsDirectory); + await RNFS.mkdir(draftDirectory); + + await RNFS.copyFile(result.uri, draftFilePath); + + const webViewUrl = `file://${draftFilePath}`; + return { fieldId, status: 'success' as const, data: { - filename: result.name, - uri: result.uri, - size: result.size || 0, - mimeType: result.type || 'application/octet-stream', type: 'file' as const, + filename: basename, + uri: draftFilePath, + url: webViewUrl, + size: result.size ?? 0, + mimeType: result.type || 'application/octet-stream', timestamp: new Date().toISOString(), + metadata: { + extension: ext, + originalFileName: originalName, + }, }, }; } catch (error) { @@ -852,8 +918,13 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { }; } try { + const attachmentsDirectory = `${RNFS.DocumentDirectoryPath}/attachments`; + const draftDirectory = `${attachmentsDirectory}/draft`; const filename = `audio_${Date.now()}.m4a`; - const path = `${RNFS.DocumentDirectoryPath}/${filename}`; + const path = `${draftDirectory}/${filename}`; + + await RNFS.mkdir(attachmentsDirectory); + await RNFS.mkdir(draftDirectory); const audioSet: AudioSet = { // Common settings automatically applied to the appropriate platform @@ -875,12 +946,15 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { data: { type: 'audio' as const, filename: filename, - uri: `file://${path}`, + uri: path, + url: `file://${path}`, timestamp: new Date().toISOString(), metadata: { duration: 3.0, format: 'm4a', size: fileStats.size || 0, + sampleRate: 44100, + channels: 1, }, }, };