From 0c00263534faf99894fbf46dfc50616bcc9f24c7 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 09:08:43 -0300 Subject: [PATCH 01/55] [#49]: Add expo-audio and expo-crypto for draft recording Install expo-audio (~56.0.12) for microphone capture and playback, and expo-crypto (~56.0.4) for RFC 4122 v4 IDs used as recordings.id. Register expo-audio as a config plugin with a Fluent-specific microphone permission string so prebuild injects RECORD_AUDIO and MODIFY_AUDIO_SETTINGS into the Android manifest. Refs #49 Co-authored-by: Cursor --- app.config.ts | 7 + package-lock.json | 413 ++++++++++++---------------------------------- package.json | 2 + 3 files changed, 112 insertions(+), 310 deletions(-) diff --git a/app.config.ts b/app.config.ts index 2f3eb57..42623bc 100644 --- a/app.config.ts +++ b/app.config.ts @@ -74,6 +74,13 @@ const config: ExpoConfig = { ], './plugins/withRNScreensFragmentFactory', 'expo-secure-store', + [ + 'expo-audio', + { + microphonePermission: + 'Fluent needs access to your microphone so translators can record verse drafts.', + }, + ], ], }; diff --git a/package-lock.json b/package-lock.json index e46bae6..4603695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,9 @@ "@tanstack/react-query": "^5.96.1", "babel-preset-expo": "~56.0.0", "expo": "~56.0.13", + "expo-audio": "~56.0.12", "expo-build-properties": "~56.0.20", + "expo-crypto": "~56.0.4", "expo-dev-client": "~56.0.21", "expo-secure-store": "~56.0.4", "expo-updates": "~56.0.20", @@ -1646,40 +1648,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-react-pure-annotations": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", @@ -1700,7 +1668,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" @@ -2124,34 +2092,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -4726,42 +4666,6 @@ "node": ">= 10" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -6715,15 +6619,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8171,6 +8066,83 @@ } } }, + "node_modules/expo-asset": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.2.tgz", + "integrity": "sha512-58QjXdeklQmauYS7VlxFAk2O/KNsHqpfplHKLcgDQhg3ek26+3LL+lxWwha92JOBcHNUQoqPN47SoEsD2Yjj8A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@expo/image-utils": "^0.11.0", + "expo-constants": "~57.0.2" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-asset/node_modules/@expo/image-utils": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.0.tgz", + "integrity": "sha512-tXFTMAl79rOiK3q8cCw00CioMxv1XfM31B6B+9IK/yeWKJrwny8sSIJ0IzZWqHy6F23rW5AQVIyT5PJZ5p/hmA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@expo/require-utils": "^57.0.0", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "node_modules/expo-asset/node_modules/@expo/require-utils": { + "version": "57.0.0", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.0.tgz", + "integrity": "sha512-6PPvmqdAz7ikxJUwg+g/JV1KKQJS1xkkh/r+z4tucvBelrM0vMnENV2QVVn4qDWS0FfSIcwuSYvvx+4od/Sa9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/expo-asset/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo-audio": { + "version": "56.0.12", + "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-56.0.12.tgz", + "integrity": "sha512-ne2UIO/HsQoBL9e+tGs5N9Sf3NyW5sJMm4sDkexbSJRc2IchLDG+9Msu/+l5N4RlZ8SiF42wRyWsh/Usg+SwOw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "expo-asset": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-build-properties": { "version": "56.0.20", "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.20.tgz", @@ -8206,6 +8178,29 @@ "node": ">=10" } }, + "node_modules/expo-constants": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.2.tgz", + "integrity": "sha512-Bq0lIOB+olAQrYCS/fZi0cu1i8V0lo/YNr9fqMELerOfYYFkg8zTVrVaRjdFdMYBmz3dtym26UEBxV5dyUYZOg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@expo/env": "~2.4.0" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-crypto": { + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz", + "integrity": "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-dev-client": { "version": "56.0.21", "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.21.tgz", @@ -12435,15 +12430,6 @@ "node": ">=10" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -14625,55 +14611,6 @@ "react-native-svg": ">=12.0.0" } }, - "node_modules/react-native/node_modules/@react-native/babel-preset": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.85.3.tgz", - "integrity": "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-transform-async-generator-functions": "^7.25.4", - "@babel/plugin-transform-async-to-generator": "^7.24.7", - "@babel/plugin-transform-block-scoping": "^7.25.0", - "@babel/plugin-transform-class-properties": "^7.25.4", - "@babel/plugin-transform-classes": "^7.25.4", - "@babel/plugin-transform-destructuring": "^7.24.8", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-for-of": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.8", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-react-display-name": "^7.24.7", - "@babel/plugin-transform-react-jsx": "^7.25.2", - "@babel/plugin-transform-react-jsx-self": "^7.24.7", - "@babel/plugin-transform-react-jsx-source": "^7.24.7", - "@babel/plugin-transform-regenerator": "^7.24.7", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/plugin-transform-typescript": "^7.25.2", - "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@react-native/babel-plugin-codegen": "0.85.3", - "babel-plugin-syntax-hermes-parser": "0.33.3", - "babel-plugin-transform-flow-enums": "^0.0.2", - "react-refresh": "^0.14.0" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - }, - "peerDependencies": { - "@babel/core": "*" - } - }, "node_modules/react-native/node_modules/@react-native/community-cli-plugin": { "version": "0.85.3", "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.85.3.tgz", @@ -14713,43 +14650,6 @@ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, - "node_modules/react-native/node_modules/@react-native/metro-babel-transformer": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.85.3.tgz", - "integrity": "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.25.2", - "@react-native/babel-preset": "0.85.3", - "hermes-parser": "0.33.3", - "nullthrows": "^1.1.1" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - }, - "peerDependencies": { - "@babel/core": "*" - } - }, - "node_modules/react-native/node_modules/@react-native/metro-config": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.85.3.tgz", - "integrity": "sha512-sVo6HepUmCcpdfozEf91lA0FjpLNNZYu/Zi9FiYiAQTK8pzATXDVTqhvdxpFrQn435p5eUTSbllvbH/KN+bnyA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@react-native/js-polyfills": "0.85.3", - "@react-native/metro-babel-transformer": "0.85.3", - "metro-config": "^0.84.3", - "metro-runtime": "^0.84.3" - }, - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, "node_modules/react-native/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -14759,25 +14659,6 @@ "node": ">=18" } }, - "node_modules/react-native/node_modules/hermes-estree": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", - "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/react-native/node_modules/hermes-parser": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", - "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "hermes-estree": "0.33.3" - } - }, "node_modules/react-native/node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -16467,73 +16348,6 @@ "node": ">=6.10" } }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/ts-node/node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/ts-node/node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -16902,15 +16716,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -17359,18 +17164,6 @@ "node": ">=12" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 81ddc9c..c7f2eb0 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "@tanstack/react-query": "^5.96.1", "babel-preset-expo": "~56.0.0", "expo": "~56.0.13", + "expo-audio": "~56.0.12", "expo-build-properties": "~56.0.20", + "expo-crypto": "~56.0.4", "expo-dev-client": "~56.0.21", "expo-secure-store": "~56.0.4", "expo-updates": "~56.0.20", From 156485c4f99b3a8a619cb290c741bfebd9a52782 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 09:09:04 -0300 Subject: [PATCH 02/55] [#49]: Wire recordings table with take/is_latest invariants Add Recording / RecordingRow types and RecordingSyncStatus union so the existing recordings schema is typed end-to-end. Introduce repository.insertRecording that atomically demotes the prior is_latest row for a bible_text_id, increments take_number, and inserts the new take, plus deleteRecordingById for the delete flow. Add queries.getBibleTextId to resolve verse coordinates to bible_texts.id and queries.getLatestRecordingForVerse for the Record tab review view. Cover both modules with unit tests over a mocked op-sqlite handle. Refs #49 Co-authored-by: Cursor --- src/db/queries.recordings.test.ts | 90 +++++++++++++++++++++ src/db/queries.ts | 59 ++++++++++++++ src/db/repository.recordings.test.ts | 114 +++++++++++++++++++++++++++ src/db/repository.ts | 89 +++++++++++++++++++++ src/types/db/types.ts | 36 +++++++++ 5 files changed, 388 insertions(+) create mode 100644 src/db/queries.recordings.test.ts create mode 100644 src/db/repository.recordings.test.ts diff --git a/src/db/queries.recordings.test.ts b/src/db/queries.recordings.test.ts new file mode 100644 index 0000000..3b36fd3 --- /dev/null +++ b/src/db/queries.recordings.test.ts @@ -0,0 +1,90 @@ +import { getBibleTextId, getLatestRecordingForVerse } from './queries'; + +interface MockDb { + execute: jest.Mock; +} + +let mockDb: MockDb; + +jest.mock('./db', () => ({ + getDatabase: () => mockDb, +})); + +describe('getBibleTextId', () => { + it('returns the numeric id when the verse is present', async () => { + mockDb = { + execute: jest.fn().mockResolvedValue({ rows: [{ id: 123 }] }), + }; + + const result = await getBibleTextId(1, 41, 14, 3); + + expect(result).toBe(123); + expect(mockDb.execute).toHaveBeenCalledWith( + expect.stringMatching(/FROM bible_texts/), + [1, 41, 14, 3], + ); + }); + + it('returns null when no row matches', async () => { + mockDb = { execute: jest.fn().mockResolvedValue({ rows: [] }) }; + expect(await getBibleTextId(1, 41, 14, 3)).toBeNull(); + }); + + it('returns null when the query throws', async () => { + mockDb = { + execute: jest.fn().mockRejectedValue(new Error('boom')), + }; + expect(await getBibleTextId(1, 41, 14, 3)).toBeNull(); + }); +}); + +describe('getLatestRecordingForVerse', () => { + it('maps snake_case rows into the Recording shape', async () => { + mockDb = { + execute: jest.fn().mockResolvedValue({ + rows: [ + { + id: 'rec-1', + bible_text_id: 42, + local_file_path: '/tmp/rec-1.m4a', + blob_key: null, + duration_ms: 12000, + file_size_bytes: 24000, + take_number: 2, + is_latest: 1, + sync_status: 'pending', + upload_error: null, + created_at: '2026-07-01T00:00:00.000Z', + updated_at: '2026-07-01T00:00:00.000Z', + }, + ], + }), + }; + + const result = await getLatestRecordingForVerse(42); + + expect(result).toEqual({ + id: 'rec-1', + bibleTextId: 42, + localFilePath: '/tmp/rec-1.m4a', + blobKey: undefined, + durationMs: 12000, + fileSizeBytes: 24000, + takeNumber: 2, + isLatest: true, + syncStatus: 'pending', + uploadError: undefined, + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-01T00:00:00.000Z', + }); + expect(mockDb.execute).toHaveBeenCalledWith( + expect.stringMatching(/FROM recordings/), + [42], + ); + }); + + it('returns null when no latest row exists', async () => { + mockDb = { execute: jest.fn().mockResolvedValue({ rows: [] }) }; + expect(await getLatestRecordingForVerse(42)).toBeNull(); + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index 0ff7b66..c2885e7 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -380,6 +380,65 @@ export async function getPendingUploadCount(): Promise { } } +/** Resolves the `bible_texts.id` PK a recording joins against. */ +export async function getBibleTextId( + bibleId: number, + bookId: number, + chapterNumber: number, + verseNumber: number, +): Promise { + const db = getDatabase(); + try { + const result = await db.execute( + `SELECT id FROM bible_texts + WHERE bible_id = ? AND book_id = ? AND chapter_number = ? AND verse_number = ? + LIMIT 1`, + [bibleId, bookId, chapterNumber, verseNumber], + ); + const row = (result?.rows as unknown as { id: number }[])?.[0]; + return row?.id ?? null; + } catch (error) { + log.error('Error resolving bible_text id', { error }); + return null; + } +} + +export async function getLatestRecordingForVerse( + bibleTextId: number, +): Promise { + const db = getDatabase(); + try { + const result = await db.execute( + `SELECT id, bible_text_id, local_file_path, blob_key, duration_ms, + file_size_bytes, take_number, is_latest, sync_status, upload_error, + created_at, updated_at + FROM recordings + WHERE bible_text_id = ? AND is_latest = 1 + LIMIT 1`, + [bibleTextId], + ); + const row = (result?.rows as unknown as DBTypes.RecordingRow[])?.[0]; + if (!row) return null; + return { + id: row.id, + bibleTextId: row.bible_text_id, + localFilePath: row.local_file_path, + blobKey: row.blob_key ?? undefined, + durationMs: row.duration_ms ?? undefined, + fileSizeBytes: row.file_size_bytes ?? undefined, + takeNumber: row.take_number, + isLatest: Boolean(row.is_latest), + syncStatus: row.sync_status, + uploadError: row.upload_error ?? undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } catch (error) { + log.error('Error fetching latest recording for verse', { error }); + return null; + } +} + export async function getBibleTexts( bibleId: number, bookId: number, diff --git a/src/db/repository.recordings.test.ts b/src/db/repository.recordings.test.ts new file mode 100644 index 0000000..a8797c9 --- /dev/null +++ b/src/db/repository.recordings.test.ts @@ -0,0 +1,114 @@ +import { + deleteRecordingById, + insertRecording, + type InsertRecordingInput, +} from './repository'; + +interface MockTx { + execute: jest.Mock; +} + +interface MockDb { + execute: jest.Mock; + transaction: jest.Mock, [(tx: MockTx) => Promise]>; +} + +let mockDb: MockDb; +let mockTx: MockTx; + +jest.mock('./db', () => { + return { + getDatabase: () => mockDb, + }; +}); + +function makeDb(existingMaxTake: number): MockDb { + mockTx = { + execute: jest.fn().mockImplementation((sql: string) => { + if (/MAX\(take_number\)/.test(sql)) { + return Promise.resolve({ rows: [{ max_take: existingMaxTake }] }); + } + return Promise.resolve({ rows: [] }); + }), + }; + return { + execute: jest.fn().mockResolvedValue({ rows: [] }), + transaction: jest.fn(async (cb: (t: MockTx) => Promise) => { + await cb(mockTx); + }), + }; +} + +describe('insertRecording', () => { + const baseInput: InsertRecordingInput = { + id: 'rec-1', + bibleTextId: 42, + localFilePath: '/tmp/rec-1.m4a', + durationMs: 12000, + fileSizeBytes: 24000, + createdAt: '2026-07-01T00:00:00.000Z', + }; + + it('demotes prior latest rows and inserts the new take as latest', async () => { + mockDb = makeDb(0); + + const rec = await insertRecording(baseInput); + + expect(rec.takeNumber).toBe(1); + expect(rec.isLatest).toBe(true); + expect(rec.syncStatus).toBe('pending'); + + const executeCalls = mockTx.execute.mock.calls.map( + ([sql]) => sql as string, + ); + expect(executeCalls[0]).toMatch(/MAX\(take_number\)/); + expect(executeCalls[1]).toMatch(/UPDATE recordings SET is_latest = 0/); + expect(executeCalls[2]).toMatch(/INSERT INTO recordings/); + + const insertParams = mockTx.execute.mock.calls[2]?.[1] as unknown[]; + expect(insertParams).toEqual([ + 'rec-1', + 42, + '/tmp/rec-1.m4a', + 12000, + 24000, + 1, + 'pending', + '2026-07-01T00:00:00.000Z', + '2026-07-01T00:00:00.000Z', + ]); + }); + + it('increments take_number when prior takes exist', async () => { + mockDb = makeDb(3); + + const rec = await insertRecording({ ...baseInput, id: 'rec-2' }); + + expect(rec.takeNumber).toBe(4); + const insertParams = mockTx.execute.mock.calls[2]?.[1] as unknown[]; + expect(insertParams?.[5]).toBe(4); + }); + + it('accepts a custom sync status', async () => { + mockDb = makeDb(0); + + const rec = await insertRecording({ ...baseInput, syncStatus: 'error' }); + + expect(rec.syncStatus).toBe('error'); + const insertParams = mockTx.execute.mock.calls[2]?.[1] as unknown[]; + expect(insertParams?.[6]).toBe('error'); + }); +}); + +describe('deleteRecordingById', () => { + it('runs a DELETE query with the provided id', async () => { + mockDb = makeDb(0); + + await deleteRecordingById('rec-9'); + + expect(mockDb.execute).toHaveBeenCalledWith( + expect.stringMatching(/DELETE FROM recordings WHERE id = \?/), + ['rec-9'], + ); + }); +}); diff --git a/src/db/repository.ts b/src/db/repository.ts index a4900cd..0693540 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -462,6 +462,95 @@ export async function userHasLocalProjects(userId: number): Promise { return Number(result.rows?.[0]?.count ?? 0) > 0; } +export interface InsertRecordingInput { + id: string; + bibleTextId: number; + localFilePath: string; + durationMs?: number | null; + fileSizeBytes?: number | null; + syncStatus?: DBTypes.RecordingSyncStatus; + createdAt?: string; +} + +interface MaxTakeRow { + max_take: number | null; +} + +/** + * Inserts a new recording as the latest take for a verse, demoting any prior + * `is_latest = 1` rows. Wrapped in a transaction so callers observe the row + * count atomically; upload is deferred to a future ticket. + */ +export async function insertRecording( + input: InsertRecordingInput, +): Promise { + const db = getDatabase(); + const now = input.createdAt ?? new Date().toISOString(); + const syncStatus = input.syncStatus ?? 'pending'; + + let takeNumber = 1; + + await db.transaction(async (tx: Transaction) => { + const takeResult = await tx.execute( + `SELECT COALESCE(MAX(take_number), 0) AS max_take + FROM recordings WHERE bible_text_id = ?`, + [input.bibleTextId], + ); + const rows = takeResult.rows as unknown as MaxTakeRow[]; + takeNumber = (rows?.[0]?.max_take ?? 0) + 1; + + await tx.execute( + `UPDATE recordings SET is_latest = 0, updated_at = ? + WHERE bible_text_id = ? AND is_latest = 1`, + [now, input.bibleTextId], + ); + + await tx.execute( + `INSERT INTO recordings + (id, bible_text_id, local_file_path, duration_ms, file_size_bytes, + take_number, is_latest, sync_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, + [ + input.id, + input.bibleTextId, + input.localFilePath, + input.durationMs ?? null, + input.fileSizeBytes ?? null, + takeNumber, + syncStatus, + now, + now, + ], + ); + }); + + log.info('Recording inserted', { + id: input.id, + bibleTextId: input.bibleTextId, + takeNumber, + }); + + return { + id: input.id, + bibleTextId: input.bibleTextId, + localFilePath: input.localFilePath, + durationMs: input.durationMs ?? null, + fileSizeBytes: input.fileSizeBytes ?? null, + takeNumber, + isLatest: true, + syncStatus, + createdAt: now, + updatedAt: now, + }; +} + +/** Deletes a recording row by id; caller unlinks the audio file separately. */ +export async function deleteRecordingById(id: string): Promise { + const db = getDatabase(); + await db.execute(`DELETE FROM recordings WHERE id = ?`, [id]); + log.info('Recording deleted', { id }); +} + export async function userHasLocalChapterAssignments( userId: number, ): Promise { diff --git a/src/types/db/types.ts b/src/types/db/types.ts index cf54df0..6f6f1cd 100644 --- a/src/types/db/types.ts +++ b/src/types/db/types.ts @@ -235,6 +235,42 @@ export interface VerseRow { text: string; } +export type RecordingSyncStatus = + | 'pending' + | 'uploading' + | 'uploaded' + | 'error'; + +export interface Recording { + id: string; + bibleTextId: number; + localFilePath: string; + blobKey?: string | null; + durationMs?: number | null; + fileSizeBytes?: number | null; + takeNumber: number; + isLatest: boolean; + syncStatus: RecordingSyncStatus; + uploadError?: string | null; + createdAt: string; + updatedAt: string; +} + +export interface RecordingRow { + id: string; + bible_text_id: number; + local_file_path: string; + blob_key: string | null; + duration_ms: number | null; + file_size_bytes: number | null; + take_number: number; + is_latest: number; + sync_status: RecordingSyncStatus; + upload_error: string | null; + created_at: string; + updated_at: string; +} + export const CHAPTER_ASSIGNMENT_STATUS = { not_started: 'Not Started', draft: 'Draft', From 16373c751aecf3142df2776145402855e7efbfcd Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 09:09:14 -0300 Subject: [PATCH 03/55] [#49]: Persist paused-take marker for recording recovery Add PausedTakeMarker and get/set/clearPausedTake helpers over the op-sqlite KV store so a paused take (verse, temp file URI, elapsed ms, started-at) survives app backgrounding or crash and can be resumed or recovered on next launch. Refs #49 Co-authored-by: Cursor --- src/services/storage.ts | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/services/storage.ts b/src/services/storage.ts index 5b72c2d..35637a5 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -174,3 +174,54 @@ export function setUserLastSyncedAt(userId: string, timestamp: string) { kvStorage.setItemSync(`${userId}:last_synced_at`, timestamp); log.info('Per-user last synced timestamp updated', { userId, timestamp }); } + +/** + * Paused-take marker: written when the recorder pauses (manual or via + * backgrounding) so the drafting page can detect an in-flight take on the + * next mount, even after a process kill. + */ +export interface PausedTakeMarker { + bibleTextId: number; + fileUri: string; + elapsedMs: number; + startedAt: string; +} + +function pausedTakeKey(bibleTextId: number): string { + return `paused_take:${bibleTextId}`; +} + +export function getPausedTake(bibleTextId: number): PausedTakeMarker | null { + const raw = kvStorage.getItemSync(pausedTakeKey(bibleTextId)); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as PausedTakeMarker; + if ( + typeof parsed?.fileUri === 'string' && + typeof parsed?.elapsedMs === 'number' && + typeof parsed?.startedAt === 'string' + ) { + return parsed; + } + return null; + } catch (error) { + log.error('Failed to parse paused take marker', { bibleTextId, error }); + return null; + } +} + +export function setPausedTake(marker: PausedTakeMarker) { + kvStorage.setItemSync( + pausedTakeKey(marker.bibleTextId), + JSON.stringify(marker), + ); + log.info('Paused take marker set', { + bibleTextId: marker.bibleTextId, + elapsedMs: marker.elapsedMs, + }); +} + +export function clearPausedTake(bibleTextId: number) { + kvStorage.removeItemSync(pausedTakeKey(bibleTextId)); + log.info('Paused take marker cleared', { bibleTextId }); +} From 0dee736334d69ebc6c3f213eb1c039d35ec4feec Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 09:09:36 -0300 Subject: [PATCH 04/55] [#49]: Build Record tab with draft recording and playback Replace the ViewChapter stub with a DraftingPage shell that hosts the selected verse, a disabled ChapterAudioPlayerBar placeholder for the upcoming #47 player, and a DraftingTabBar switching between a Bible placeholder and the new RecordTab. Add useRecorder hook implementing the Idle / Recording / Paused / Review state machine on top of expo-audio: request and track microphone permission (unknown | granted | denied | blocked), capture takes into a temp file, insert them via repository.insertRecording, mint recording IDs with expo-crypto randomUUID, and persist a paused- take marker via the storage KV so pauses survive backgrounding. RecordTab renders verse chevrons, the source-text accordion, per-state controls (start, pause, resume, stop, re-record, delete, playback), a duration counter, a delete confirmation, and a navigate-away prompt. When permission is blocked the tab opens system settings via Linking.openSettings, otherwise it re-requests through the OS dialog. Point AppNavigator.VerseDetail at DraftingPage and delete the legacy ViewChapter screen. Cover the hook and screen with Jest tests using the LoginScreen mocking pattern. Refs #49 Co-authored-by: Cursor --- src/app/tabs/ViewChapter.tsx | 270 -------- src/app/tabs/drafting/BibleTab.tsx | 38 ++ .../tabs/drafting/ChapterAudioPlayerBar.tsx | 65 ++ src/app/tabs/drafting/DraftingPage.tsx | 163 +++++ src/app/tabs/drafting/DraftingTabBar.tsx | 77 +++ src/app/tabs/drafting/RecordTab.test.tsx | 250 +++++++ src/app/tabs/drafting/RecordTab.tsx | 633 ++++++++++++++++++ src/hooks/useRecorder.test.ts | 308 +++++++++ src/hooks/useRecorder.ts | 349 ++++++++++ src/navigation/AppNavigator.tsx | 4 +- 10 files changed, 1885 insertions(+), 272 deletions(-) delete mode 100644 src/app/tabs/ViewChapter.tsx create mode 100644 src/app/tabs/drafting/BibleTab.tsx create mode 100644 src/app/tabs/drafting/ChapterAudioPlayerBar.tsx create mode 100644 src/app/tabs/drafting/DraftingPage.tsx create mode 100644 src/app/tabs/drafting/DraftingTabBar.tsx create mode 100644 src/app/tabs/drafting/RecordTab.test.tsx create mode 100644 src/app/tabs/drafting/RecordTab.tsx create mode 100644 src/hooks/useRecorder.test.ts create mode 100644 src/hooks/useRecorder.ts diff --git a/src/app/tabs/ViewChapter.tsx b/src/app/tabs/ViewChapter.tsx deleted file mode 100644 index 34a2c32..0000000 --- a/src/app/tabs/ViewChapter.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - View, - Text, - TouchableOpacity, - ScrollView, - LayoutAnimation, - Platform, - UIManager, - ActivityIndicator, -} from 'react-native'; -import { logger } from '../../utils/logger'; -import { Ionicons } from '@react-native-vector-icons/ionicons'; -import { appStyles as styles } from '../appStyles'; -import { RootStackParamList } from '../../types/navigation/types'; -import { ChapterAssignmentData, VerseData } from '../../types/db/types'; -import { getChapterAssignmentById, getBibleTexts } from '../../db/queries'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; - -const log = logger.create('ViewChapter'); - -if (Platform.OS === 'android') { - UIManager.setLayoutAnimationEnabledExperimental?.(true); -} - -type Route = RouteProp; -type VerseState = 'idle' | 'recording' | 'recorded'; - -export default function ViewChapter() { - const navigation = useNavigation(); - const { chapterId, chapterName, language, projectName } = - useRoute().params; - - const [selectedVerse, setSelectedVerse] = useState(1); - const [sourceExpanded, setSourceExpanded] = useState(false); - const [verseStates, setVerseStates] = useState>( - {}, - ); - const [verses, setVerses] = useState([]); - const [loading, setLoading] = useState(true); - const [chapterData, setChapterData] = useState( - null, - ); - - useEffect(() => { - const loadVerses = async () => { - try { - setLoading(true); - - const assignment = await getChapterAssignmentById(chapterId); - - if (assignment) { - setChapterData(assignment); - - const texts = await getBibleTexts( - assignment.bibleId, - assignment.bookId, - assignment.chapterNumber, - ); - - setVerses(texts); - - if (texts && texts.length > 0) { - const firstVerseNumber = texts[0]?.verseNumber; - if (firstVerseNumber !== null && firstVerseNumber !== undefined) { - setSelectedVerse(firstVerseNumber); - } - } - } - } catch (error) { - log.error('Error loading verses:', { error }); - } finally { - setLoading(false); - } - }; - - loadVerses(); - }, [chapterId]); - - if (loading) { - return ( - - - - ); - } - - if (!chapterData) { - return ( - - No chapter data found - - ); - } - - const verseState = verseStates[selectedVerse] ?? 'idle'; - const selectedVerseData = verses.find(v => v.verseNumber === selectedVerse); - const sourceText = selectedVerseData?.text; - - function toggleSource() { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - setSourceExpanded(prev => !prev); - } - - function handleRecord() { - setVerseStates((prev: Record) => ({ - ...prev, - [selectedVerse]: - prev[selectedVerse] === 'recording' ? 'recorded' : 'recording', - })); - } - - function handleDelete() { - setVerseStates((prev: Record) => ({ - ...prev, - [selectedVerse]: 'idle', - })); - } - - function selectVerse(v: number) { - setSelectedVerse(v); - setSourceExpanded(false); - } - - return ( - - navigation.goBack()} - style={styles.backBtn} - activeOpacity={0.7} - > - - - {chapterName} - {projectName} - - - - - - - {chapterData.bibleName} - Verse {selectedVerse} - - - - - - - - - - - - - - {sourceExpanded ? 'Hide Source Text' : 'Show Source Text'} - - - - - {sourceExpanded && sourceText && ( - - {sourceText} - - )} - - - - - {language} - Verse {selectedVerse} - - - {verseState === 'idle' && ( - - - - )} - - {verseState === 'recording' && ( - - - - )} - - {verseState === 'recorded' && ( - <> - - - - - - - - - - - - - )} - - - - - {verses.length > 0 ? ( - verses.map(verse => { - const hasRecording = - (verseStates[verse.verseNumber] ?? 'idle') === 'recorded'; - const isSelected = selectedVerse === verse.verseNumber; - return ( - selectVerse(verse.verseNumber)} - activeOpacity={0.7} - > - {hasRecording && ( - - )} - - {verse.verseNumber} - - - ); - }) - ) : ( - No verses found - )} - - - ); -} diff --git a/src/app/tabs/drafting/BibleTab.tsx b/src/app/tabs/drafting/BibleTab.tsx new file mode 100644 index 0000000..4e4908f --- /dev/null +++ b/src/app/tabs/drafting/BibleTab.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { theme } from '../../../theme'; + +/** + * Placeholder Bible tab. Full implementation lands in issue #48. + */ +export function BibleTab() { + return ( + + Bible tab + + The Bible tab is built in issue #48. Use the Record tab to draft the + currently selected verse. + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing.xl, + gap: theme.spacing.md, + }, + title: { + fontSize: theme.typography.sizes.lg, + fontWeight: theme.typography.weights.semibold, + color: theme.colors.foreground, + }, + body: { + fontSize: theme.typography.sizes.sm, + color: theme.colors.mutedForeground, + textAlign: 'center', + }, +}); diff --git a/src/app/tabs/drafting/ChapterAudioPlayerBar.tsx b/src/app/tabs/drafting/ChapterAudioPlayerBar.tsx new file mode 100644 index 0000000..25927ac --- /dev/null +++ b/src/app/tabs/drafting/ChapterAudioPlayerBar.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { Play } from 'lucide-react-native'; +import { theme } from '../../../theme'; +import { iconSizes, listIconStrokeWidth } from '../../../theme/iconSpecs'; + +/** + * Placeholder chapter audio player bar owned by drafting page shell #47. + * Rendered here so #49 can be built and manually verified end-to-end; #47 will + * replace it with a full-featured player. + */ +export function ChapterAudioPlayerBar() { + return ( + + + + + + Chapter audio · coming soon + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.md, + paddingHorizontal: theme.spacing.lg, + paddingVertical: theme.spacing.md, + backgroundColor: theme.colors.cardBackground, + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + playButton: { + width: 40, + height: 40, + borderRadius: theme.radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.border, + }, + waveformStub: { + flex: 1, + height: 24, + justifyContent: 'center', + borderRadius: theme.radius.sm, + backgroundColor: theme.colors.border, + paddingHorizontal: theme.spacing.md, + }, + label: { + fontSize: theme.typography.sizes.xs, + color: theme.colors.mutedForeground, + }, +}); diff --git a/src/app/tabs/drafting/DraftingPage.tsx b/src/app/tabs/drafting/DraftingPage.tsx new file mode 100644 index 0000000..fd73db3 --- /dev/null +++ b/src/app/tabs/drafting/DraftingPage.tsx @@ -0,0 +1,163 @@ +import React, { useEffect, useState } from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { theme } from '../../../theme'; +import type { RootStackParamList } from '../../../types/navigation/types'; +import type { ChapterAssignmentData, VerseData } from '../../../types/db/types'; +import { + getBibleTextId, + getBibleTexts, + getChapterAssignmentById, +} from '../../../db/queries'; +import { StackScreenHeader } from '../../../components/layout/StackScreenHeader'; +import { logger } from '../../../utils/logger'; +import { BibleTab } from './BibleTab'; +import { ChapterAudioPlayerBar } from './ChapterAudioPlayerBar'; +import { DraftingTab, DraftingTabBar } from './DraftingTabBar'; +import { RecordTab } from './RecordTab'; + +const log = logger.create('DraftingPage'); + +type Route = RouteProp; + +/** + * Parent shell for the drafting page. Owns the selected verse and active tab + * per issue #47 (this is a stub of that shell); the Record tab implementation + * lives in {@link RecordTab} for issue #49. + */ +export default function DraftingPage() { + const navigation = useNavigation(); + const { chapterId, chapterName, projectName } = useRoute().params; + + const [chapterData, setChapterData] = useState( + null, + ); + const [verses, setVerses] = useState([]); + const [selectedVerse, setSelectedVerse] = useState(1); + const [bibleTextId, setBibleTextId] = useState(null); + const [activeTab, setActiveTab] = useState('record'); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + async function loadChapter() { + try { + setLoading(true); + const assignment = await getChapterAssignmentById(chapterId); + if (!assignment) return; + if (cancelled) return; + setChapterData(assignment); + + const texts = await getBibleTexts( + assignment.bibleId, + assignment.bookId, + assignment.chapterNumber, + ); + if (cancelled) return; + setVerses(texts); + if (texts.length > 0) { + setSelectedVerse(prev => { + const stillValid = texts.some(t => t.verseNumber === prev); + return stillValid ? prev : texts[0]?.verseNumber ?? 1; + }); + } + } catch (error) { + log.error('Failed to load drafting page data', { chapterId, error }); + } finally { + if (!cancelled) setLoading(false); + } + } + + loadChapter(); + return () => { + cancelled = true; + }; + }, [chapterId]); + + useEffect(() => { + let cancelled = false; + + async function resolveId() { + if (!chapterData) { + setBibleTextId(null); + return; + } + const id = await getBibleTextId( + chapterData.bibleId, + chapterData.bookId, + chapterData.chapterNumber, + selectedVerse, + ); + if (!cancelled) setBibleTextId(id); + } + + resolveId(); + return () => { + cancelled = true; + }; + }, [chapterData, selectedVerse]); + + const bookName = chapterData?.bookName ?? chapterName; + const chapterNumber = chapterData?.chapterNumber ?? 1; + + if (loading) { + return ( + + + + ); + } + + if (!chapterData) { + return ( + + No chapter data found + + ); + } + + return ( + + navigation.goBack()} + /> + + {activeTab === 'record' ? ( + + ) : ( + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + centered: { + alignItems: 'center', + justifyContent: 'center', + }, + emptyText: { + fontSize: theme.typography.sizes.md, + color: theme.colors.mutedForeground, + }, + body: { + flex: 1, + }, +}); diff --git a/src/app/tabs/drafting/DraftingTabBar.tsx b/src/app/tabs/drafting/DraftingTabBar.tsx new file mode 100644 index 0000000..37901af --- /dev/null +++ b/src/app/tabs/drafting/DraftingTabBar.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Headphones, LucideIcon, Mic } from 'lucide-react-native'; +import { theme } from '../../../theme'; +import { iconSizes, listIconStrokeWidth } from '../../../theme/iconSpecs'; + +export type DraftingTab = 'bible' | 'record'; + +interface DraftingTabBarProps { + activeTab: DraftingTab; + onTabChange: (tab: DraftingTab) => void; +} + +const TABS: { id: DraftingTab; label: string; Icon: LucideIcon }[] = [ + { id: 'bible', label: 'Bible', Icon: Headphones }, + { id: 'record', label: 'Record', Icon: Mic }, +]; + +export function DraftingTabBar({ + activeTab, + onTabChange, +}: DraftingTabBarProps) { + return ( + + {TABS.map(({ id, label, Icon }) => { + const isActive = activeTab === id; + const color = isActive + ? theme.colors.primary + : theme.colors.mutedForeground; + + return ( + onTabChange(id)} + accessibilityRole="tab" + accessibilityLabel={`${label} tab`} + accessibilityState={{ selected: isActive }} + testID={`drafting-tab-${id}`} + > + + {label} + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + backgroundColor: theme.colors.tabBarBackground, + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + tab: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 2, + paddingVertical: theme.spacing.md, + borderTopWidth: 2, + borderTopColor: 'transparent', + }, + activeTab: { + borderTopColor: theme.colors.primary, + }, + label: { + fontSize: theme.typography.sizes.xs, + fontWeight: theme.typography.weights.medium, + }, +}); diff --git a/src/app/tabs/drafting/RecordTab.test.tsx b/src/app/tabs/drafting/RecordTab.test.tsx new file mode 100644 index 0000000..6e8ae2c --- /dev/null +++ b/src/app/tabs/drafting/RecordTab.test.tsx @@ -0,0 +1,250 @@ +import React from 'react'; +import { Alert } from 'react-native'; +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react-native'; +import { RecordTab } from './RecordTab'; +import type { VerseData } from '../../../types/db/types'; + +const mockUseRecorder = jest.fn(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + addListener: jest.fn(() => jest.fn()), + dispatch: jest.fn(), + goBack: jest.fn(), + }), +})); + +jest.mock('../../../hooks/useRecorder', () => ({ + useRecorder: () => mockUseRecorder(), +})); + +const VERSES: VerseData[] = [ + { bibleId: 1, bookId: 41, chapterNumber: 14, verseNumber: 1, text: 'v1' }, + { bibleId: 1, bookId: 41, chapterNumber: 14, verseNumber: 2, text: 'v2' }, + { bibleId: 1, bookId: 41, chapterNumber: 14, verseNumber: 3, text: 'v3' }, +]; + +function baseRecorderState() { + return { + status: 'idle' as const, + elapsedMs: 0, + permission: 'granted' as const, + currentRecording: null, + isReady: true, + requestPermission: jest + .fn() + .mockResolvedValue({ granted: true, canAskAgain: true }), + start: jest.fn().mockResolvedValue(undefined), + pause: jest.fn().mockResolvedValue(undefined), + resume: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + reRecord: jest.fn().mockResolvedValue(undefined), + deleteCurrent: jest.fn().mockResolvedValue(undefined), + discardPaused: jest.fn().mockResolvedValue(undefined), + }; +} + +function renderTab( + overrides: Partial> = {}, +) { + return render( + , + ); +} + +describe('RecordTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the record button labelled with the verse reference in idle', () => { + mockUseRecorder.mockReturnValue(baseRecorderState()); + renderTab(); + + expect(screen.getByTestId('record-verse-reference')).toHaveTextContent( + 'Mark 14:3', + ); + expect(screen.getByTestId('record-start-button')).toHaveTextContent( + 'Record Mark 14:3', + ); + }); + + it('shows pause and stop controls with a duration when recording', () => { + mockUseRecorder.mockReturnValue({ + ...baseRecorderState(), + status: 'recording', + elapsedMs: 65_000, + }); + renderTab(); + + expect(screen.getByTestId('record-duration')).toHaveTextContent('1:05'); + expect(screen.getByTestId('record-pause-button')).toBeTruthy(); + expect(screen.getByTestId('record-stop-button')).toBeTruthy(); + }); + + it('shows the resume button while paused', () => { + mockUseRecorder.mockReturnValue({ + ...baseRecorderState(), + status: 'paused', + elapsedMs: 3_000, + }); + renderTab(); + + expect(screen.getByTestId('record-resume-button')).toBeTruthy(); + expect(screen.queryByTestId('record-pause-button')).toBeNull(); + }); + + it('renders review controls with delete confirmation prompt', () => { + const state = { + ...baseRecorderState(), + status: 'review' as const, + currentRecording: { + id: 'rec-1', + bibleTextId: 42, + localFilePath: '/tmp/rec-1.m4a', + takeNumber: 1, + isLatest: true, + syncStatus: 'pending' as const, + createdAt: 'x', + updatedAt: 'x', + }, + }; + mockUseRecorder.mockReturnValue(state); + + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + renderTab(); + + fireEvent.press(screen.getByTestId('record-delete-button')); + expect(alertSpy).toHaveBeenCalledWith( + 'Delete draft?', + expect.any(String), + expect.any(Array), + ); + + alertSpy.mockRestore(); + }); + + it('disables prev on the first verse and next on the last verse', () => { + mockUseRecorder.mockReturnValue(baseRecorderState()); + const { rerender } = renderTab({ selectedVerseNumber: 1 }); + + expect( + screen.getByTestId('record-prev-verse').props.accessibilityState, + ).toEqual(expect.objectContaining({ disabled: true })); + expect( + screen.getByTestId('record-next-verse').props.accessibilityState, + ).toEqual(expect.objectContaining({ disabled: false })); + + rerender( + , + ); + + expect( + screen.getByTestId('record-next-verse').props.accessibilityState, + ).toEqual(expect.objectContaining({ disabled: true })); + }); + + it('prompts the OS permission dialog on first tap when permission is denied', async () => { + const requestPermission = jest + .fn() + .mockResolvedValue({ granted: true, canAskAgain: true }); + const start = jest.fn().mockResolvedValue(undefined); + mockUseRecorder.mockReturnValue({ + ...baseRecorderState(), + permission: 'denied', + requestPermission, + start, + }); + + renderTab(); + fireEvent.press(screen.getByTestId('record-start-button')); + + await waitFor(() => expect(requestPermission).toHaveBeenCalled()); + await waitFor(() => expect(start).toHaveBeenCalled()); + }); + + it('shows the Settings alert instead of starting when permission is blocked', async () => { + const requestPermission = jest.fn(); + const start = jest.fn(); + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + mockUseRecorder.mockReturnValue({ + ...baseRecorderState(), + permission: 'blocked', + requestPermission, + start, + }); + + renderTab(); + fireEvent.press(screen.getByTestId('record-start-button')); + + await waitFor(() => + expect(alertSpy).toHaveBeenCalledWith( + 'Microphone access required', + expect.stringContaining('Settings'), + expect.any(Array), + ), + ); + expect(requestPermission).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + + alertSpy.mockRestore(); + }); + + it('shows the Settings alert when the OS permanently denies during the prompt', async () => { + const requestPermission = jest + .fn() + .mockResolvedValue({ granted: false, canAskAgain: false }); + const start = jest.fn(); + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + mockUseRecorder.mockReturnValue({ + ...baseRecorderState(), + permission: 'denied', + requestPermission, + start, + }); + + renderTab(); + fireEvent.press(screen.getByTestId('record-start-button')); + + await waitFor(() => expect(requestPermission).toHaveBeenCalled()); + await waitFor(() => + expect(alertSpy).toHaveBeenCalledWith( + 'Microphone access required', + expect.stringContaining('Settings'), + expect.any(Array), + ), + ); + expect(start).not.toHaveBeenCalled(); + + alertSpy.mockRestore(); + }); + + it('source text accordion is collapsed by default and toggles open', () => { + mockUseRecorder.mockReturnValue(baseRecorderState()); + renderTab(); + + expect(screen.queryByTestId('record-source-body')).toBeNull(); + fireEvent.press(screen.getByTestId('record-source-toggle')); + expect(screen.getByTestId('record-source-body')).toHaveTextContent('v3'); + }); +}); diff --git a/src/app/tabs/drafting/RecordTab.tsx b/src/app/tabs/drafting/RecordTab.tsx new file mode 100644 index 0000000..0701974 --- /dev/null +++ b/src/app/tabs/drafting/RecordTab.tsx @@ -0,0 +1,633 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + LayoutAnimation, + Linking, + Platform, + StyleSheet, + Text, + TouchableOpacity, + UIManager, + View, +} from 'react-native'; +import { + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, + CircleDot, + Pause, + Play, + StopCircle, + Trash2, +} from 'lucide-react-native'; +import { useNavigation } from '@react-navigation/native'; +import { theme } from '../../../theme'; +import { iconSizes, listIconStrokeWidth } from '../../../theme/iconSpecs'; +import type { VerseData } from '../../../types/db/types'; +import { useRecorder } from '../../../hooks/useRecorder'; + +if (Platform.OS === 'android') { + UIManager.setLayoutAnimationEnabledExperimental?.(true); +} + +interface RecordTabProps { + bookName: string; + chapterNumber: number; + verses: VerseData[]; + selectedVerseNumber: number; + bibleTextIdForSelectedVerse: number | null; + onSelectVerse: (verseNumber: number) => void; +} + +function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function verseReference(bookName: string, chapter: number, verse: number) { + return `${bookName} ${chapter}:${verse}`; +} + +export function RecordTab({ + bookName, + chapterNumber, + verses, + selectedVerseNumber, + bibleTextIdForSelectedVerse, + onSelectVerse, +}: RecordTabProps) { + const navigation = useNavigation(); + const recorder = useRecorder({ + bibleTextId: bibleTextIdForSelectedVerse, + }); + const [sourceExpanded, setSourceExpanded] = useState(false); + + const currentReference = verseReference( + bookName, + chapterNumber, + selectedVerseNumber, + ); + + const verseIndex = verses.findIndex( + v => v.verseNumber === selectedVerseNumber, + ); + const canGoPrev = verseIndex > 0; + const canGoNext = verseIndex >= 0 && verseIndex < verses.length - 1; + + const sourceText = useMemo( + () => verses.find(v => v.verseNumber === selectedVerseNumber)?.text ?? '', + [verses, selectedVerseNumber], + ); + + useEffect(() => { + if (recorder.status !== 'paused') return; + + const beforeRemove = (event: { + preventDefault: () => void; + data: { action: unknown }; + }) => { + event.preventDefault(); + Alert.alert( + 'Recording in progress', + 'You have a paused recording. Resume it or discard the take before leaving.', + [ + { text: 'Resume', style: 'cancel' }, + { + text: 'Discard', + style: 'destructive', + onPress: async () => { + await recorder.discardPaused(); + // Re-dispatch original navigation action after discarding. + ( + navigation as unknown as { dispatch: (a: unknown) => void } + ).dispatch(event.data.action); + }, + }, + ], + ); + }; + + const unsubscribe = ( + navigation as unknown as { + addListener: ( + event: 'beforeRemove', + cb: typeof beforeRemove, + ) => () => void; + } + ).addListener('beforeRemove', beforeRemove); + + return unsubscribe; + }, [navigation, recorder]); + + function withPausedGuard(action: () => void) { + if (recorder.status === 'paused') { + Alert.alert( + 'Recording in progress', + 'Resume or discard the paused take before switching verses.', + [ + { text: 'Resume', style: 'cancel' }, + { + text: 'Discard', + style: 'destructive', + onPress: async () => { + await recorder.discardPaused(); + action(); + }, + }, + ], + ); + return; + } + action(); + } + + function handlePrev() { + if (!canGoPrev) return; + withPausedGuard(() => onSelectVerse(verses[verseIndex - 1]!.verseNumber)); + } + + function handleNext() { + if (!canGoNext) return; + withPausedGuard(() => onSelectVerse(verses[verseIndex + 1]!.verseNumber)); + } + + function toggleSource() { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setSourceExpanded(prev => !prev); + } + + function showMicBlockedAlert() { + Alert.alert( + 'Microphone access required', + 'Fluent needs microphone access to record verses. Enable it in Settings.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Go to Settings', + onPress: () => { + Linking.openSettings().catch(() => undefined); + }, + }, + ], + ); + } + + /** + * Ensures microphone permission is granted, prompting the OS dialog when + * possible and only surfacing the Settings deep-link when the OS suppresses + * the prompt (permanent denial). Returns whether recording may proceed. + */ + async function ensureMicPermission(): Promise { + if (recorder.permission === 'granted') return true; + if (recorder.permission === 'blocked') { + showMicBlockedAlert(); + return false; + } + const { granted, canAskAgain } = await recorder.requestPermission(); + if (granted) return true; + if (!canAskAgain) showMicBlockedAlert(); + return false; + } + + async function handleStartPress() { + if (!(await ensureMicPermission())) return; + await recorder.start(); + } + + async function handleReRecordPress() { + if (!(await ensureMicPermission())) return; + await recorder.reRecord(); + } + + async function handleDeletePress() { + Alert.alert('Delete draft?', 'This cannot be undone.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + await recorder.deleteCurrent(); + }, + }, + ]); + } + + return ( + + + + + + + {currentReference} + + + + + + + {recorder.status === 'recording' || recorder.status === 'paused' ? ( + + {Array.from({ length: 24 }).map((_, index) => { + const active = recorder.status === 'recording'; + const height = + 8 + + ((Math.abs(Math.sin((recorder.elapsedMs + index * 40) / 90)) * + 24) | + 0); + return ( + + ); + })} + + ) : recorder.status === 'review' ? ( + + {Array.from({ length: 24 }).map((_, index) => ( + + ))} + + ) : null} + + + {recorder.status === 'idle' && ( + + + + Record {currentReference} + + + )} + + {recorder.status === 'recording' && ( + + + {formatDuration(recorder.elapsedMs)} + + + recorder.pause()} + accessibilityRole="button" + accessibilityLabel="Pause recording" + testID="record-pause-button" + > + + + recorder.stop()} + accessibilityRole="button" + accessibilityLabel="Stop recording" + testID="record-stop-button" + > + + + + + )} + + {recorder.status === 'paused' && ( + + + {formatDuration(recorder.elapsedMs)} + + + recorder.resume()} + accessibilityRole="button" + accessibilityLabel="Resume recording" + testID="record-resume-button" + > + + + recorder.stop()} + accessibilityRole="button" + accessibilityLabel="Stop recording" + testID="record-stop-button" + > + + + + + )} + + {recorder.status === 'review' && ( + + { + // Playback is stubbed until #47 pipes the chapter audio player. + // Individual take playback still runs via expo-audio in future work. + }} + accessibilityRole="button" + accessibilityLabel="Play draft" + testID="record-play-button" + > + + + + + + Re-record + + + + Delete + + + + )} + + + + + {sourceExpanded ? 'Hide source text' : 'Show source text'} + + {sourceExpanded ? ( + + ) : ( + + )} + + {sourceExpanded && ( + + {sourceText} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: theme.spacing.lg, + gap: theme.spacing.lg, + }, + verseNav: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + verseNavButton: { + padding: theme.spacing.sm, + }, + verseReference: { + fontSize: theme.typography.sizes.xl, + fontWeight: theme.typography.weights.bold, + color: theme.colors.foreground, + }, + disabledIcon: { + opacity: 0.35, + }, + waveform: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + height: 40, + }, + waveformBar: { + width: 4, + backgroundColor: theme.colors.primary, + borderRadius: 2, + }, + controls: { + alignItems: 'center', + gap: theme.spacing.md, + }, + recordButton: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.md, + paddingHorizontal: theme.spacing.xl, + paddingVertical: theme.spacing.md, + backgroundColor: theme.colors.primary, + borderRadius: theme.radius.full, + }, + recordButtonLabel: { + color: theme.colors.primaryForeground, + fontSize: theme.typography.sizes.md, + fontWeight: theme.typography.weights.semibold, + }, + recordingRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + }, + recordingButtons: { + flexDirection: 'row', + gap: theme.spacing.md, + }, + duration: { + fontSize: theme.typography.sizes.xl, + fontWeight: theme.typography.weights.bold, + fontVariant: ['tabular-nums'], + color: theme.colors.foreground, + }, + secondaryButton: { + width: 56, + height: 56, + borderRadius: theme.radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.cardBackground, + borderWidth: 1, + borderColor: theme.colors.border, + flexDirection: 'row', + gap: theme.spacing.sm, + }, + stopButton: { + width: 64, + height: 64, + borderRadius: theme.radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.primary, + }, + buttonLabel: { + color: theme.colors.foreground, + fontSize: theme.typography.sizes.sm, + fontWeight: theme.typography.weights.medium, + }, + destructiveLabel: { + color: theme.colors.destructive, + fontSize: theme.typography.sizes.sm, + fontWeight: theme.typography.weights.medium, + }, + reviewGroup: { + alignItems: 'center', + gap: theme.spacing.md, + }, + playButton: { + width: 64, + height: 64, + borderRadius: theme.radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.primary, + }, + reviewActions: { + flexDirection: 'row', + gap: theme.spacing.md, + }, + destructiveButton: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.sm, + paddingHorizontal: theme.spacing.md, + paddingVertical: theme.spacing.sm, + borderRadius: theme.radius.full, + borderWidth: 1, + borderColor: theme.colors.destructive, + }, + accordionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing.md, + backgroundColor: theme.colors.cardBackground, + borderRadius: theme.radius.md, + }, + accordionLabel: { + fontSize: theme.typography.sizes.sm, + fontWeight: theme.typography.weights.medium, + color: theme.colors.foreground, + }, + sourceBody: { + padding: theme.spacing.md, + }, + sourceText: { + fontSize: theme.typography.sizes.md, + lineHeight: theme.typography.lineHeights.normal, + color: theme.colors.foreground, + }, +}); diff --git a/src/hooks/useRecorder.test.ts b/src/hooks/useRecorder.test.ts new file mode 100644 index 0000000..95f3971 --- /dev/null +++ b/src/hooks/useRecorder.test.ts @@ -0,0 +1,308 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { AppState } from 'react-native'; +import { useRecorder } from './useRecorder'; + +const mockRecorder = { + currentTime: 0, + uri: 'file:///tmp/take-1.m4a', + record: jest.fn(), + pause: jest.fn(), + stop: jest.fn().mockResolvedValue(undefined), + prepareToRecordAsync: jest.fn().mockResolvedValue(undefined), +}; + +const mockInsertRecording = jest.fn(); +const mockDeleteRecordingById = jest.fn().mockResolvedValue(undefined); +const mockGetLatestRecordingForVerse = jest.fn().mockResolvedValue(null); +const mockGetPausedTake = jest.fn().mockReturnValue(null); +const mockSetPausedTake = jest.fn(); +const mockClearPausedTake = jest.fn(); + +jest.mock('expo-crypto', () => ({ + randomUUID: jest.fn(() => 'test-uuid-1'), +})); + +jest.mock('expo-audio', () => ({ + RecordingPresets: { HIGH_QUALITY: {} }, + useAudioRecorder: () => mockRecorder, + useAudioPlayer: () => ({ play: jest.fn(), pause: jest.fn() }), + useAudioPlayerStatus: () => ({}), + requestRecordingPermissionsAsync: jest.fn().mockResolvedValue({ + granted: true, + canAskAgain: true, + status: 'granted', + }), + getRecordingPermissionsAsync: jest.fn().mockResolvedValue({ + granted: true, + canAskAgain: true, + status: 'granted', + }), + setAudioModeAsync: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../db/repository', () => ({ + insertRecording: (input: unknown) => mockInsertRecording(input), + deleteRecordingById: (id: string) => mockDeleteRecordingById(id), +})); + +jest.mock('../db/queries', () => ({ + getLatestRecordingForVerse: (id: number) => + mockGetLatestRecordingForVerse(id), +})); + +jest.mock('../services/storage', () => ({ + getPausedTake: (id: number) => mockGetPausedTake(id), + setPausedTake: (marker: unknown) => mockSetPausedTake(marker), + clearPausedTake: (id: number) => mockClearPausedTake(id), +})); + +async function waitReady(result: { current: { isReady: boolean } }) { + await waitFor(() => expect(result.current.isReady).toBe(true)); +} + +describe('useRecorder', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRecorder.currentTime = 0; + mockRecorder.uri = 'file:///tmp/take-1.m4a'; + mockGetLatestRecordingForVerse.mockResolvedValue(null); + mockGetPausedTake.mockReturnValue(null); + mockInsertRecording.mockImplementation(async input => ({ + id: input.id, + bibleTextId: input.bibleTextId, + localFilePath: input.localFilePath, + durationMs: input.durationMs ?? null, + fileSizeBytes: input.fileSizeBytes ?? null, + takeNumber: 1, + isLatest: true, + syncStatus: 'pending' as const, + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-01T00:00:00.000Z', + })); + }); + + it('starts in idle when the verse has no existing draft', async () => { + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + expect(result.current.status).toBe('idle'); + expect(result.current.currentRecording).toBeNull(); + }); + + it('starts in review when a draft already exists', async () => { + mockGetLatestRecordingForVerse.mockResolvedValueOnce({ + id: 'existing', + bibleTextId: 42, + localFilePath: '/tmp/existing.m4a', + takeNumber: 1, + isLatest: true, + syncStatus: 'pending', + createdAt: 'x', + updatedAt: 'x', + }); + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + expect(result.current.status).toBe('review'); + expect(result.current.currentRecording?.id).toBe('existing'); + }); + + it('surfaces a persisted paused-take marker on mount', async () => { + mockGetPausedTake.mockReturnValueOnce({ + bibleTextId: 42, + fileUri: '/tmp/paused.m4a', + elapsedMs: 4500, + startedAt: '2026-07-01T00:00:00.000Z', + }); + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + expect(result.current.status).toBe('paused'); + expect(result.current.elapsedMs).toBe(4500); + }); + + it('transitions idle -> recording on start', async () => { + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + + await act(async () => { + await result.current.start(); + }); + + expect(mockRecorder.prepareToRecordAsync).toHaveBeenCalled(); + expect(mockRecorder.record).toHaveBeenCalled(); + expect(result.current.status).toBe('recording'); + }); + + it('flushes to KV on pause and clears it on stop', async () => { + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + + await act(async () => { + await result.current.start(); + }); + + mockRecorder.currentTime = 2.5; + + await act(async () => { + await result.current.pause(); + }); + + expect(mockRecorder.pause).toHaveBeenCalled(); + expect(mockSetPausedTake).toHaveBeenCalledWith( + expect.objectContaining({ + bibleTextId: 42, + fileUri: 'file:///tmp/take-1.m4a', + elapsedMs: 2500, + }), + ); + expect(result.current.status).toBe('paused'); + + // resume back to recording; then stop and commit + mockRecorder.currentTime = 0; + await act(async () => { + await result.current.resume(); + }); + expect(result.current.status).toBe('recording'); + + mockRecorder.currentTime = 3; + await act(async () => { + await result.current.stop(); + }); + + expect(mockRecorder.stop).toHaveBeenCalled(); + expect(mockInsertRecording).toHaveBeenCalledWith( + expect.objectContaining({ + bibleTextId: 42, + localFilePath: 'file:///tmp/take-1.m4a', + }), + ); + expect(mockClearPausedTake).toHaveBeenCalledWith(42); + expect(result.current.status).toBe('review'); + }); + + it('re-record starts a new recording and stop overwrites the previous draft', async () => { + mockGetLatestRecordingForVerse.mockResolvedValueOnce({ + id: 'existing', + bibleTextId: 42, + localFilePath: '/tmp/existing.m4a', + takeNumber: 1, + isLatest: true, + syncStatus: 'pending', + createdAt: 'x', + updatedAt: 'x', + }); + + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + expect(result.current.status).toBe('review'); + + await act(async () => { + await result.current.reRecord(); + }); + expect(mockRecorder.record).toHaveBeenCalled(); + expect(result.current.status).toBe('recording'); + + mockRecorder.currentTime = 1; + await act(async () => { + await result.current.stop(); + }); + + expect(mockInsertRecording).toHaveBeenCalledTimes(1); + expect(result.current.status).toBe('review'); + expect(result.current.currentRecording?.id).not.toBe('existing'); + }); + + it('delete removes the current draft and returns to idle', async () => { + mockGetLatestRecordingForVerse.mockResolvedValueOnce({ + id: 'existing', + bibleTextId: 42, + localFilePath: '/tmp/existing.m4a', + takeNumber: 1, + isLatest: true, + syncStatus: 'pending', + createdAt: 'x', + updatedAt: 'x', + }); + + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + + await act(async () => { + await result.current.deleteCurrent(); + }); + + expect(mockDeleteRecordingById).toHaveBeenCalledWith('existing'); + expect(result.current.status).toBe('idle'); + expect(result.current.currentRecording).toBeNull(); + }); + + it('reports blocked permission when the OS has suppressed the prompt', async () => { + const expoAudio = jest.requireMock('expo-audio') as { + getRecordingPermissionsAsync: jest.Mock; + }; + expoAudio.getRecordingPermissionsAsync.mockResolvedValueOnce({ + granted: false, + canAskAgain: false, + status: 'denied', + }); + + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + await waitFor(() => expect(result.current.permission).toBe('blocked')); + }); + + it('surfaces canAskAgain from requestPermission and updates state accordingly', async () => { + const expoAudio = jest.requireMock('expo-audio') as { + getRecordingPermissionsAsync: jest.Mock; + requestRecordingPermissionsAsync: jest.Mock; + }; + expoAudio.getRecordingPermissionsAsync.mockResolvedValueOnce({ + granted: false, + canAskAgain: true, + status: 'undetermined', + }); + expoAudio.requestRecordingPermissionsAsync.mockResolvedValueOnce({ + granted: false, + canAskAgain: false, + status: 'denied', + }); + + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + await waitFor(() => expect(result.current.permission).toBe('denied')); + + let response: { granted: boolean; canAskAgain: boolean } | undefined; + await act(async () => { + response = await result.current.requestPermission(); + }); + + expect(response).toEqual({ granted: false, canAskAgain: false }); + expect(result.current.permission).toBe('blocked'); + }); + + it('auto-pauses when the app is backgrounded during recording', async () => { + const listeners: Array<(state: string) => void> = []; + const addSpy = jest + .spyOn(AppState, 'addEventListener') + .mockImplementation(((_event: string, cb: (state: string) => void) => { + listeners.push(cb); + return { remove: jest.fn() }; + }) as unknown as typeof AppState.addEventListener); + + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + + await act(async () => { + await result.current.start(); + }); + + await act(async () => { + listeners.forEach(cb => cb('background')); + await Promise.resolve(); + }); + + expect(mockRecorder.pause).toHaveBeenCalled(); + expect(mockSetPausedTake).toHaveBeenCalled(); + expect(result.current.status).toBe('paused'); + + addSpy.mockRestore(); + }); +}); diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts new file mode 100644 index 0000000..e5602dd --- /dev/null +++ b/src/hooks/useRecorder.ts @@ -0,0 +1,349 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; +import { + RecordingPresets, + getRecordingPermissionsAsync, + requestRecordingPermissionsAsync, + setAudioModeAsync, + useAudioRecorder, +} from 'expo-audio'; +import type { AudioRecorder } from 'expo-audio'; +import { randomUUID } from 'expo-crypto'; +import { deleteRecordingById, insertRecording } from '../db/repository'; +import { getLatestRecordingForVerse } from '../db/queries'; +import { + clearPausedTake, + getPausedTake, + setPausedTake, +} from '../services/storage'; +import type { Recording } from '../types/db/types'; +import { logger } from '../utils/logger'; + +const log = logger.create('useRecorder'); + +export type RecorderStatus = 'idle' | 'recording' | 'paused' | 'review'; +/** + * Microphone permission state. + * - `unknown` — hook still probing the OS. + * - `granted` — user has granted microphone access. + * - `denied` — not granted, but the OS will still show a prompt on request. + * - `blocked` — permanently denied; OS suppresses the prompt, only Settings recovers. + */ +export type PermissionState = 'unknown' | 'granted' | 'denied' | 'blocked'; + +export interface PermissionRequestResult { + granted: boolean; + canAskAgain: boolean; +} + +/** + * Wraps `expo-audio`'s recorder and player primitives with the state machine + * described in issue #49: Idle -> Recording -> Paused -> Review, plus + * Re-record and Delete transitions from Review. + * + * `bibleTextId === null` keeps the recorder inert until a verse is resolved. + */ +export interface UseRecorderArgs { + bibleTextId: number | null; +} + +export interface UseRecorderApi { + status: RecorderStatus; + elapsedMs: number; + permission: PermissionState; + currentRecording: Recording | null; + isReady: boolean; + + requestPermission: () => Promise; + start: () => Promise; + pause: () => Promise; + resume: () => Promise; + stop: () => Promise; + reRecord: () => Promise; + deleteCurrent: () => Promise; + discardPaused: () => Promise; +} + +const TICK_INTERVAL_MS = 200; + +interface PermissionLike { + granted: boolean; + canAskAgain?: boolean; +} + +function mapPermissionState(response: PermissionLike): PermissionState { + if (response.granted) return 'granted'; + if (response.canAskAgain === false) return 'blocked'; + return 'denied'; +} + +export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { + const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); + + const [status, setStatus] = useState('idle'); + const [permission, setPermission] = useState('unknown'); + const [elapsedMs, setElapsedMs] = useState(0); + const [currentRecording, setCurrentRecording] = useState( + null, + ); + const [isReady, setIsReady] = useState(false); + + const tickRef = useRef | null>(null); + const startedAtRef = useRef(null); + const baseElapsedRef = useRef(0); + const bibleTextIdRef = useRef(bibleTextId); + + bibleTextIdRef.current = bibleTextId; + + const clearTick = useCallback(() => { + if (tickRef.current) { + clearInterval(tickRef.current); + tickRef.current = null; + } + }, []); + + const startTicking = useCallback(() => { + clearTick(); + tickRef.current = setInterval(() => { + setElapsedMs( + baseElapsedRef.current + Math.round(recorder.currentTime * 1000), + ); + }, TICK_INTERVAL_MS); + }, [clearTick, recorder]); + + // Load latest DB recording for the current verse and reset state. + useEffect(() => { + let cancelled = false; + + async function load() { + setIsReady(false); + setElapsedMs(0); + baseElapsedRef.current = 0; + startedAtRef.current = null; + clearTick(); + + if (bibleTextId === null) { + setStatus('idle'); + setCurrentRecording(null); + return; + } + + const [latest, paused] = await Promise.all([ + getLatestRecordingForVerse(bibleTextId), + Promise.resolve(getPausedTake(bibleTextId)), + ]); + + if (cancelled) return; + + setCurrentRecording(latest); + + if (paused) { + // Surface the marker so the UI can prompt Resume/Discard on next mount. + setStatus('paused'); + setElapsedMs(paused.elapsedMs); + baseElapsedRef.current = paused.elapsedMs; + startedAtRef.current = paused.startedAt; + } else { + setStatus(latest ? 'review' : 'idle'); + } + setIsReady(true); + } + + load().catch(error => { + log.error('Failed to load recorder state', { bibleTextId, error }); + }); + + return () => { + cancelled = true; + }; + }, [bibleTextId, clearTick]); + + // Ensure permission state is populated without prompting the user. + useEffect(() => { + let cancelled = false; + getRecordingPermissionsAsync() + .then(response => { + if (cancelled) return; + setPermission(mapPermissionState(response)); + }) + .catch(error => log.error('Failed to check mic permission', { error })); + return () => { + cancelled = true; + }; + }, []); + + // Auto-pause when the app is backgrounded mid-recording. + useEffect(() => { + const subscription = AppState.addEventListener( + 'change', + (nextState: AppStateStatus) => { + if (nextState !== 'active' && status === 'recording') { + pauseInternal().catch(error => + log.error('Auto-pause on background failed', { error }), + ); + } + }, + ); + return () => subscription.remove(); + // pauseInternal is stable via closure and eslint-disable to avoid re-subscribing every tick. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status]); + + useEffect(() => () => clearTick(), [clearTick]); + + const persistPausedMarker = useCallback( + (rec: AudioRecorder, elapsed: number) => { + const currentBibleTextId = bibleTextIdRef.current; + if (currentBibleTextId === null || !rec.uri) return; + const startedAt = startedAtRef.current ?? new Date().toISOString(); + startedAtRef.current = startedAt; + setPausedTake({ + bibleTextId: currentBibleTextId, + fileUri: rec.uri, + elapsedMs: elapsed, + startedAt, + }); + }, + [], + ); + + const requestPermission = + useCallback(async (): Promise => { + const response = await requestRecordingPermissionsAsync(); + setPermission(mapPermissionState(response)); + return { + granted: response.granted, + canAskAgain: response.canAskAgain ?? true, + }; + }, []); + + const startRecordingSession = useCallback(async () => { + await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true }); + await recorder.prepareToRecordAsync(); + baseElapsedRef.current = 0; + startedAtRef.current = new Date().toISOString(); + setElapsedMs(0); + recorder.record(); + setStatus('recording'); + startTicking(); + }, [recorder, startTicking]); + + const start = useCallback(async () => { + const currentBibleTextId = bibleTextIdRef.current; + if (currentBibleTextId === null) return; + if (permission !== 'granted') { + const { granted } = await requestPermission(); + if (!granted) return; + } + await startRecordingSession(); + }, [permission, requestPermission, startRecordingSession]); + + const pauseInternal = useCallback(async () => { + const currentBibleTextId = bibleTextIdRef.current; + if (currentBibleTextId === null) return; + if (status !== 'recording') return; + + const finalElapsed = + baseElapsedRef.current + Math.round(recorder.currentTime * 1000); + recorder.pause(); + clearTick(); + baseElapsedRef.current = finalElapsed; + setElapsedMs(finalElapsed); + setStatus('paused'); + persistPausedMarker(recorder, finalElapsed); + }, [clearTick, persistPausedMarker, recorder, status]); + + const pause = useCallback(async () => { + await pauseInternal(); + }, [pauseInternal]); + + const resume = useCallback(async () => { + if (status !== 'paused') return; + recorder.record(); + setStatus('recording'); + startTicking(); + }, [recorder, startTicking, status]); + + const commitRecording = useCallback(async () => { + const currentBibleTextId = bibleTextIdRef.current; + if (currentBibleTextId === null) return; + await recorder.stop(); + clearTick(); + + const duration = elapsedMs; + const fileUri = recorder.uri; + if (!fileUri) { + log.error('Recorder returned no URI on stop; skipping DB write'); + setStatus(currentRecording ? 'review' : 'idle'); + return; + } + + const inserted = await insertRecording({ + id: randomUUID(), + bibleTextId: currentBibleTextId, + localFilePath: fileUri, + durationMs: duration, + }); + + clearPausedTake(currentBibleTextId); + baseElapsedRef.current = 0; + startedAtRef.current = null; + setCurrentRecording(inserted); + setStatus('review'); + }, [clearTick, currentRecording, elapsedMs, recorder]); + + const stop = useCallback(async () => { + if (status !== 'recording' && status !== 'paused') return; + await commitRecording(); + }, [commitRecording, status]); + + const reRecord = useCallback(async () => { + if (status !== 'review') return; + if (permission !== 'granted') { + const { granted } = await requestPermission(); + if (!granted) return; + } + await startRecordingSession(); + }, [permission, requestPermission, startRecordingSession, status]); + + const deleteCurrent = useCallback(async () => { + if (status !== 'review' || !currentRecording) return; + await deleteRecordingById(currentRecording.id); + setCurrentRecording(null); + setStatus('idle'); + setElapsedMs(0); + baseElapsedRef.current = 0; + }, [currentRecording, status]); + + const discardPaused = useCallback(async () => { + const currentBibleTextId = bibleTextIdRef.current; + if (currentBibleTextId === null) return; + try { + await recorder.stop(); + } catch (error) { + log.warn('Recorder stop while discarding paused take failed', { error }); + } + clearPausedTake(currentBibleTextId); + clearTick(); + baseElapsedRef.current = 0; + startedAtRef.current = null; + setElapsedMs(0); + setStatus(currentRecording ? 'review' : 'idle'); + }, [clearTick, currentRecording, recorder]); + + return { + status, + elapsedMs, + permission, + currentRecording, + isReady, + requestPermission, + start, + pause, + resume, + stop, + reRecord, + deleteCurrent, + discardPaused, + }; +} diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 138fdb7..eba7c2d 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -5,7 +5,7 @@ import HomeScreen from '../app/screens/HomeScreen'; import SettingsScreen from '../app/screens/SettingsScreen'; import PrepareForOfflineScreen from '../app/screens/PrepareForOfflineScreen'; import ViewProject from '../app/tabs/ViewProject'; -import ViewChapter from '../app/tabs/ViewChapter'; +import DraftingPage from '../app/tabs/drafting/DraftingPage'; import LoginScreen from '../app/tabs/LoginScreen'; import ForgotPasswordScreen from '../app/tabs/ForgotPasswordScreen'; import PrivacyPolicyScreen from '../app/tabs/PrivacyPolicyPage'; @@ -60,7 +60,7 @@ export default function AppNavigator({ component={PrepareForOfflineScreen} /> - + {({ navigation }) => ( Date: Thu, 2 Jul 2026 09:09:47 -0300 Subject: [PATCH 05/55] [#49]: Document drafting page and recording wiring Point the onboarding map and project-overview rule at drafting/DraftingPage and drafting/RecordTab. Mark the recordings- table wiring TODO as done and record the remaining follow-ups (#47 chapter audio player, upload/sync). Refs #49 Co-authored-by: Cursor --- .cursor/rules/project-overview.mdc | 2 +- docs/AGENT_ONBOARDING.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.cursor/rules/project-overview.mdc b/.cursor/rules/project-overview.mdc index ee74b27..faffe37 100644 --- a/.cursor/rules/project-overview.mdc +++ b/.cursor/rules/project-overview.mdc @@ -11,7 +11,7 @@ Offline-first **Expo SDK 56** (React Native **0.85**, React **19.2.3**) companio - Entry: `App.tsx` → `initializeDatabase()` → `syncAllData()` → `AppNavigator` - App config: `app.config.ts` (EAS project ID, plugins, package id) -- Screens: `src/app/tabs/` (`ProjectList`, `ViewProject`, `ViewChapter`) +- Screens: `src/app/tabs/` (`ProjectList`, `ViewProject`, `drafting/DraftingPage` + `RecordTab`) - API: `src/services/api.ts` (`FluentAPI`) - Sync: `src/services/sync.ts` - DB writes: `src/db/repository.ts` | reads: `src/db/queries.ts` diff --git a/docs/AGENT_ONBOARDING.md b/docs/AGENT_ONBOARDING.md index a3cd449..671f713 100644 --- a/docs/AGENT_ONBOARDING.md +++ b/docs/AGENT_ONBOARDING.md @@ -30,7 +30,7 @@ Quick map for Cursor agents and new contributors. Verified against Expo SDK 56 + | Path | Purpose | |------|---------| | [`App.tsx`](../App.tsx) | Root: DB init + initial `syncAllData`, then navigator | -| [`src/app/tabs/`](../src/app/tabs/) | Screens: `ProjectList`, `ViewProject`, `ViewChapter` | +| [`src/app/tabs/`](../src/app/tabs/) | Screens: `ProjectList`, `ViewProject`, `drafting/DraftingPage` (parent shell stub for #47) with `RecordTab` (#49) | | [`src/navigation/`](../src/navigation/) | Stack navigator | | [`src/services/api.ts`](../src/services/api.ts) | HTTP client (`FluentAPI`) | | [`src/services/sync.ts`](../src/services/sync.ts) | Sync orchestration, retries, KV counts | @@ -206,7 +206,9 @@ When adding features: mock `op-sqlite`, navigation, and sync in screen tests fol - [x] Dependabot reviewers: `eten-tech-foundation/fluent-admin` - [ ] Mock or gate `fluent-api.test.ts` for offline CI - [ ] Align `format:check` glob with `format` or document intentionally narrow check -- [ ] Wire `recordings` table to actual audio capture/upload +- [x] Wire `recordings` table to actual audio capture (issue #49 — upload still pending) +- [ ] Upload/sync draft recordings to the Fluent server (`sync_status='pending'` rows) +- [ ] Replace parent drafting shell stub with full #47 implementation (chapter audio player, tab persistence) ## Related docs From 667c0a410eac2a6348cb4a93cf7678959917aac3 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 09:48:55 -0300 Subject: [PATCH 06/55] [#49]: Sharpen idle record button to circular icon-only affordance Split the pill-shaped record CTA into an 88px red circle carrying just the target icon, a centered verse-reference label below it, and a disabled play-button placeholder that hints at future draft playback. Add a dedicated recordAccent theme token so the red is not overloaded with destructive semantics. Update the RecordTab smoke test for the new record-start-label and record-play-idle-placeholder test IDs. Refs #49 Co-authored-by: Cursor --- src/app/tabs/drafting/RecordTab.test.tsx | 6 +- src/app/tabs/drafting/RecordTab.tsx | 70 +++++++++++++++++------- src/theme/tokens.ts | 2 + 3 files changed, 55 insertions(+), 23 deletions(-) diff --git a/src/app/tabs/drafting/RecordTab.test.tsx b/src/app/tabs/drafting/RecordTab.test.tsx index 6e8ae2c..61266a9 100644 --- a/src/app/tabs/drafting/RecordTab.test.tsx +++ b/src/app/tabs/drafting/RecordTab.test.tsx @@ -70,16 +70,18 @@ describe('RecordTab', () => { jest.clearAllMocks(); }); - it('renders the record button labelled with the verse reference in idle', () => { + it('renders the idle record button with a label and disabled play hint', () => { mockUseRecorder.mockReturnValue(baseRecorderState()); renderTab(); expect(screen.getByTestId('record-verse-reference')).toHaveTextContent( 'Mark 14:3', ); - expect(screen.getByTestId('record-start-button')).toHaveTextContent( + expect(screen.getByTestId('record-start-button')).toBeTruthy(); + expect(screen.getByTestId('record-start-label')).toHaveTextContent( 'Record Mark 14:3', ); + expect(screen.getByTestId('record-play-idle-placeholder')).toBeTruthy(); }); it('shows pause and stop controls with a duration when recording', () => { diff --git a/src/app/tabs/drafting/RecordTab.tsx b/src/app/tabs/drafting/RecordTab.tsx index 0701974..f72f78d 100644 --- a/src/app/tabs/drafting/RecordTab.tsx +++ b/src/app/tabs/drafting/RecordTab.tsx @@ -309,22 +309,37 @@ export function RecordTab({ {recorder.status === 'idle' && ( - - - + + + + + Record {currentReference} - + + + + )} {recorder.status === 'recording' && ( @@ -523,19 +538,32 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: theme.spacing.md, }, - recordButton: { - flexDirection: 'row', + idleGroup: { alignItems: 'center', gap: theme.spacing.md, - paddingHorizontal: theme.spacing.xl, - paddingVertical: theme.spacing.md, - backgroundColor: theme.colors.primary, + }, + recordButtonCircle: { + width: 88, + height: 88, borderRadius: theme.radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.recordAccent, }, recordButtonLabel: { - color: theme.colors.primaryForeground, + color: theme.colors.foreground, fontSize: theme.typography.sizes.md, - fontWeight: theme.typography.weights.semibold, + fontWeight: theme.typography.weights.medium, + textAlign: 'center', + }, + playButtonDisabled: { + width: 48, + height: 48, + borderRadius: theme.radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.cardBackground, + opacity: 0.6, }, recordingRow: { flexDirection: 'row', diff --git a/src/theme/tokens.ts b/src/theme/tokens.ts index cf11549..9124f1f 100644 --- a/src/theme/tokens.ts +++ b/src/theme/tokens.ts @@ -68,6 +68,8 @@ export const colors = { syncStatusPending: '#E48F06', syncStatusOffline: '#A0A0A0', destructive: '#DC2626', + /** Record button + active-recording affordance (drafting Record tab). */ + recordAccent: '#DC2626', workflowBadgeDraftBorder: '#FBBF24', workflowBadgeDraftText: '#1A1A1A', workflowBadgePeerCheckBorder: '#EA580C', From 9ccfe4de0ab3069cf08c5b1fb1a367cc5cccff07 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 11:01:40 -0300 Subject: [PATCH 07/55] [#49]: Polish Record tab live and review states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the recording controls so the muted stop circle sits on the left and the prominent red pause circle on the right, mirror the same layout for paused (stop + resume), and lift the duration counter above the buttons in muted tabular-nums. Retune the live waveform to a narrower, taller red band with 22 rounded bars and split the bar style into live (red) and static (blue) so the review waveform keeps its own accent. Add per-state coaching tips under the buttons — "Tap pause to study the source, stop to finish." while recording, and "Recording paused — review the source below, then resume." while paused. Rework the review state to mirror the idle affordance: a muted record-done placeholder next to a prominent blue play circle, with Re-record and Delete moved into labeled rounded rectangles (light card fill and destructive-outlined) using a shared placeholder-circle style with the idle play hint. Extend RecordTab.test.tsx to cover the recording tip, the paused-tip wording, and the new review-state controls. Refs #49 Co-authored-by: Cursor --- src/app/tabs/drafting/RecordTab.test.tsx | 32 ++- src/app/tabs/drafting/RecordTab.tsx | 238 ++++++++++++++--------- 2 files changed, 171 insertions(+), 99 deletions(-) diff --git a/src/app/tabs/drafting/RecordTab.test.tsx b/src/app/tabs/drafting/RecordTab.test.tsx index 61266a9..4b0c070 100644 --- a/src/app/tabs/drafting/RecordTab.test.tsx +++ b/src/app/tabs/drafting/RecordTab.test.tsx @@ -84,7 +84,7 @@ describe('RecordTab', () => { expect(screen.getByTestId('record-play-idle-placeholder')).toBeTruthy(); }); - it('shows pause and stop controls with a duration when recording', () => { + it('shows pause and stop controls with a duration and tip when recording', () => { mockUseRecorder.mockReturnValue({ ...baseRecorderState(), status: 'recording', @@ -95,6 +95,22 @@ describe('RecordTab', () => { expect(screen.getByTestId('record-duration')).toHaveTextContent('1:05'); expect(screen.getByTestId('record-pause-button')).toBeTruthy(); expect(screen.getByTestId('record-stop-button')).toBeTruthy(); + expect(screen.getByTestId('record-tip')).toHaveTextContent( + 'Tap pause to study the source, stop to finish.', + ); + }); + + it('shows the paused tip instead of the recording tip while paused', () => { + mockUseRecorder.mockReturnValue({ + ...baseRecorderState(), + status: 'paused', + elapsedMs: 3_000, + }); + renderTab(); + + expect(screen.getByTestId('record-tip')).toHaveTextContent( + 'Recording paused — review the source below, then resume.', + ); }); it('shows the resume button while paused', () => { @@ -109,7 +125,7 @@ describe('RecordTab', () => { expect(screen.queryByTestId('record-pause-button')).toBeNull(); }); - it('renders review controls with delete confirmation prompt', () => { + it('renders review controls with placeholder, play, re-record and delete', () => { const state = { ...baseRecorderState(), status: 'review' as const, @@ -129,6 +145,18 @@ describe('RecordTab', () => { const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); renderTab(); + expect( + screen.getByTestId('record-review-record-done-placeholder'), + ).toBeTruthy(); + expect(screen.getByTestId('record-play-button')).toBeTruthy(); + expect(screen.getByTestId('record-rerecord-button')).toHaveTextContent( + 'Re-record', + ); + expect(screen.getByTestId('record-delete-button')).toHaveTextContent( + 'Delete', + ); + expect(screen.queryByTestId('record-tip')).toBeNull(); + fireEvent.press(screen.getByTestId('record-delete-button')); expect(alertSpy).toHaveBeenCalledWith( 'Delete draft?', diff --git a/src/app/tabs/drafting/RecordTab.tsx b/src/app/tabs/drafting/RecordTab.tsx index f72f78d..6c13182 100644 --- a/src/app/tabs/drafting/RecordTab.tsx +++ b/src/app/tabs/drafting/RecordTab.tsx @@ -18,7 +18,8 @@ import { CircleDot, Pause, Play, - StopCircle, + RefreshCw, + Square, Trash2, } from 'lucide-react-native'; import { useNavigation } from '@react-navigation/native'; @@ -31,6 +32,8 @@ if (Platform.OS === 'android') { UIManager.setLayoutAnimationEnabledExperimental?.(true); } +const LIVE_WAVEFORM_BARS = 22; + interface RecordTabProps { bookName: string; chapterNumber: number; @@ -265,20 +268,20 @@ export function RecordTab({ testID="record-waveform-live" accessibilityLabel="Recording waveform" > - {Array.from({ length: 24 }).map((_, index) => { + {Array.from({ length: LIVE_WAVEFORM_BARS }).map((_, index) => { const active = recorder.status === 'recording'; const height = - 8 + + 14 + ((Math.abs(Math.sin((recorder.elapsedMs + index * 40) / 90)) * - 24) | + 48) | 0); return ( - {Array.from({ length: 24 }).map((_, index) => ( + {Array.from({ length: LIVE_WAVEFORM_BARS }).map((_, index) => ( + {formatDuration(recorder.elapsedMs)} - + recorder.pause()} + style={styles.stopCircleButton} + onPress={() => recorder.stop()} accessibilityRole="button" - accessibilityLabel="Pause recording" - testID="record-pause-button" + accessibilityLabel="Stop recording" + testID="record-stop-button" > - recorder.stop()} + style={styles.primaryActionCircle} + onPress={() => recorder.pause()} accessibilityRole="button" - accessibilityLabel="Stop recording" - testID="record-stop-button" + accessibilityLabel="Pause recording" + testID="record-pause-button" > - + + Tap pause to study the source, stop to finish. + )} {recorder.status === 'paused' && ( - + {formatDuration(recorder.elapsedMs)} - + recorder.resume()} + style={styles.stopCircleButton} + onPress={() => recorder.stop()} accessibilityRole="button" - accessibilityLabel="Resume recording" - testID="record-resume-button" + accessibilityLabel="Stop recording" + testID="record-stop-button" > - recorder.stop()} + style={styles.primaryActionCircle} + onPress={() => recorder.resume()} accessibilityRole="button" - accessibilityLabel="Stop recording" - testID="record-stop-button" + accessibilityLabel="Resume recording" + testID="record-resume-button" > - + + Recording paused — review the source below, then resume. + )} {recorder.status === 'review' && ( - { - // Playback is stubbed until #47 pipes the chapter audio player. - // Individual take playback still runs via expo-audio in future work. - }} - accessibilityRole="button" - accessibilityLabel="Play draft" - testID="record-play-button" - > - - + + + + + { + // Playback is stubbed until #47 pipes the chapter audio player. + // Individual take playback still runs via expo-audio in future work. + }} + accessibilityRole="button" + accessibilityLabel="Play draft" + testID="record-play-button" + > + + + - - Re-record + Re-record @@ -527,12 +550,21 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - height: 40, + height: 72, + width: '50%', + // alignContent: 'center', + alignSelf: 'center', + // paddingHorizontal: theme.spacing.xs, }, - waveformBar: { - width: 4, + waveformBarLive: { + width: 5, + borderRadius: 2.5, + backgroundColor: theme.colors.recordAccent, + }, + waveformBarStatic: { + width: 5, + borderRadius: 2.5, backgroundColor: theme.colors.primary, - borderRadius: 2, }, controls: { alignItems: 'center', @@ -556,7 +588,7 @@ const styles = StyleSheet.create({ fontWeight: theme.typography.weights.medium, textAlign: 'center', }, - playButtonDisabled: { + mutedPlaceholderCircle: { width: 48, height: 48, borderRadius: theme.radius.full, @@ -565,23 +597,27 @@ const styles = StyleSheet.create({ backgroundColor: theme.colors.cardBackground, opacity: 0.6, }, - recordingRow: { - flexDirection: 'row', + captureGroup: { alignItems: 'center', - justifyContent: 'space-between', - width: '100%', + gap: theme.spacing.md, }, - recordingButtons: { + captureButtonsRow: { flexDirection: 'row', - gap: theme.spacing.md, + alignItems: 'center', + gap: theme.spacing.lg, + }, + captureTip: { + fontSize: theme.typography.sizes.sm, + color: theme.colors.mutedForeground, + textAlign: 'center', }, duration: { - fontSize: theme.typography.sizes.xl, - fontWeight: theme.typography.weights.bold, + fontSize: 32, + fontWeight: theme.typography.weights.medium, fontVariant: ['tabular-nums'], - color: theme.colors.foreground, + color: theme.colors.mutedForeground, }, - secondaryButton: { + stopCircleButton: { width: 56, height: 56, borderRadius: theme.radius.full, @@ -590,34 +626,22 @@ const styles = StyleSheet.create({ backgroundColor: theme.colors.cardBackground, borderWidth: 1, borderColor: theme.colors.border, - flexDirection: 'row', - gap: theme.spacing.sm, }, - stopButton: { - width: 64, - height: 64, + primaryActionCircle: { + width: 72, + height: 72, borderRadius: theme.radius.full, alignItems: 'center', justifyContent: 'center', - backgroundColor: theme.colors.primary, - }, - buttonLabel: { - color: theme.colors.foreground, - fontSize: theme.typography.sizes.sm, - fontWeight: theme.typography.weights.medium, - }, - destructiveLabel: { - color: theme.colors.destructive, - fontSize: theme.typography.sizes.sm, - fontWeight: theme.typography.weights.medium, + backgroundColor: theme.colors.recordAccent, }, reviewGroup: { alignItems: 'center', - gap: theme.spacing.md, + gap: theme.spacing.lg, }, - playButton: { - width: 64, - height: 64, + primaryPlayCircle: { + width: 72, + height: 72, borderRadius: theme.radius.full, alignItems: 'center', justifyContent: 'center', @@ -627,15 +651,35 @@ const styles = StyleSheet.create({ flexDirection: 'row', gap: theme.spacing.md, }, + reRecordButton: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.sm, + paddingHorizontal: theme.spacing.lg, + paddingVertical: theme.spacing.md, + borderRadius: theme.radius.md, + backgroundColor: theme.colors.cardBackground, + }, + reRecordLabel: { + color: theme.colors.foreground, + fontSize: theme.typography.sizes.md, + fontWeight: theme.typography.weights.medium, + }, destructiveButton: { flexDirection: 'row', alignItems: 'center', gap: theme.spacing.sm, - paddingHorizontal: theme.spacing.md, - paddingVertical: theme.spacing.sm, - borderRadius: theme.radius.full, + paddingHorizontal: theme.spacing.lg, + paddingVertical: theme.spacing.md, + borderRadius: theme.radius.md, borderWidth: 1, borderColor: theme.colors.destructive, + backgroundColor: theme.colors.background, + }, + destructiveLabel: { + color: theme.colors.destructive, + fontSize: theme.typography.sizes.md, + fontWeight: theme.typography.weights.medium, }, accordionHeader: { flexDirection: 'row', From 73509adbe21160b1508f1e2371251c02a007dcb0 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 11:25:33 -0300 Subject: [PATCH 08/55] [#49]: Lock verse chevrons while recording Compose prevDisabled / nextDisabled from the existing edge flags plus a recording-active check so the prev/next chevrons cannot navigate away mid-take. Paused, idle, and review keep their existing guards (paused still prompts Resume/Discard via withPausedGuard). The muted foreground color and reduced-opacity icon style follow the disabled state so the lock is visually obvious. Refs #49 Co-authored-by: Cursor --- src/app/tabs/drafting/RecordTab.test.tsx | 16 +++++++++++++ src/app/tabs/drafting/RecordTab.tsx | 29 ++++++++++++++++-------- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/app/tabs/drafting/RecordTab.test.tsx b/src/app/tabs/drafting/RecordTab.test.tsx index 4b0c070..950e027 100644 --- a/src/app/tabs/drafting/RecordTab.test.tsx +++ b/src/app/tabs/drafting/RecordTab.test.tsx @@ -167,6 +167,22 @@ describe('RecordTab', () => { alertSpy.mockRestore(); }); + it('disables verse chevrons while recording even when navigation is possible', () => { + mockUseRecorder.mockReturnValue({ + ...baseRecorderState(), + status: 'recording', + elapsedMs: 4_000, + }); + renderTab({ selectedVerseNumber: 2 }); + + expect( + screen.getByTestId('record-prev-verse').props.accessibilityState, + ).toEqual(expect.objectContaining({ disabled: true })); + expect( + screen.getByTestId('record-next-verse').props.accessibilityState, + ).toEqual(expect.objectContaining({ disabled: true })); + }); + it('disables prev on the first verse and next on the last verse', () => { mockUseRecorder.mockReturnValue(baseRecorderState()); const { rerender } = renderTab({ selectedVerseNumber: 1 }); diff --git a/src/app/tabs/drafting/RecordTab.tsx b/src/app/tabs/drafting/RecordTab.tsx index 6c13182..bd870ae 100644 --- a/src/app/tabs/drafting/RecordTab.tsx +++ b/src/app/tabs/drafting/RecordTab.tsx @@ -79,6 +79,11 @@ export function RecordTab({ ); const canGoPrev = verseIndex > 0; const canGoNext = verseIndex >= 0 && verseIndex < verses.length - 1; + // Verse navigation is locked mid-recording so the take stays anchored to + // the verse the user started on. Paused/idle/review keep their own guards. + const isRecordingLocked = recorder.status === 'recording'; + const prevDisabled = !canGoPrev || isRecordingLocked; + const nextDisabled = !canGoNext || isRecordingLocked; const sourceText = useMemo( () => verses.find(v => v.verseNumber === selectedVerseNumber)?.text ?? '', @@ -148,12 +153,12 @@ export function RecordTab({ } function handlePrev() { - if (!canGoPrev) return; + if (prevDisabled) return; withPausedGuard(() => onSelectVerse(verses[verseIndex - 1]!.verseNumber)); } function handleNext() { - if (!canGoNext) return; + if (nextDisabled) return; withPausedGuard(() => onSelectVerse(verses[verseIndex + 1]!.verseNumber)); } @@ -225,18 +230,20 @@ export function RecordTab({ onPress={handlePrev} accessibilityRole="button" accessibilityLabel="Previous verse" - accessibilityState={{ disabled: !canGoPrev }} - disabled={!canGoPrev} + accessibilityState={{ disabled: prevDisabled }} + disabled={prevDisabled} style={styles.verseNavButton} testID="record-prev-verse" > @@ -246,18 +253,20 @@ export function RecordTab({ onPress={handleNext} accessibilityRole="button" accessibilityLabel="Next verse" - accessibilityState={{ disabled: !canGoNext }} - disabled={!canGoNext} + accessibilityState={{ disabled: nextDisabled }} + disabled={nextDisabled} style={styles.verseNavButton} testID="record-next-verse" > From 7128bfd88ccaf3562cb3017b9435ad7656fb6bae Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 11:27:19 -0300 Subject: [PATCH 09/55] [#49]: Track recording elapsed by wall clock and show centiseconds Recorder previously polled recorder.currentTime which keeps advancing on some devices while the native pipeline is paused, so elapsedMs inflated past a Resume by the length of every prior pause. Replace it with wall-clock timing: baseElapsedRef accumulates completed segments, runningSinceRef marks the start of the current active segment, and each tick reports base + (now - runningSince). Pause commits the segment before native pause, resume opens a new segment, and stop derives the final duration from the same refs so persistence matches the display. Drop the tick interval to 50 ms so the readout is smooth at centisecond precision, and format duration as MM:SS:HH with zero-padded minutes so verses over a minute align in the UI. Refs #49 Co-authored-by: Cursor --- src/app/tabs/drafting/RecordTab.test.tsx | 4 +- src/app/tabs/drafting/RecordTab.tsx | 8 +- src/hooks/useRecorder.test.ts | 101 +++++++++++++---------- src/hooks/useRecorder.ts | 41 +++++++-- 4 files changed, 96 insertions(+), 58 deletions(-) diff --git a/src/app/tabs/drafting/RecordTab.test.tsx b/src/app/tabs/drafting/RecordTab.test.tsx index 950e027..95771ff 100644 --- a/src/app/tabs/drafting/RecordTab.test.tsx +++ b/src/app/tabs/drafting/RecordTab.test.tsx @@ -88,11 +88,11 @@ describe('RecordTab', () => { mockUseRecorder.mockReturnValue({ ...baseRecorderState(), status: 'recording', - elapsedMs: 65_000, + elapsedMs: 65_420, }); renderTab(); - expect(screen.getByTestId('record-duration')).toHaveTextContent('1:05'); + expect(screen.getByTestId('record-duration')).toHaveTextContent('01:05:42'); expect(screen.getByTestId('record-pause-button')).toBeTruthy(); expect(screen.getByTestId('record-stop-button')).toBeTruthy(); expect(screen.getByTestId('record-tip')).toHaveTextContent( diff --git a/src/app/tabs/drafting/RecordTab.tsx b/src/app/tabs/drafting/RecordTab.tsx index bd870ae..f910fa8 100644 --- a/src/app/tabs/drafting/RecordTab.tsx +++ b/src/app/tabs/drafting/RecordTab.tsx @@ -44,10 +44,14 @@ interface RecordTabProps { } function formatDuration(ms: number): string { - const totalSeconds = Math.floor(ms / 1000); + const safeMs = ms < 0 ? 0 : ms; + const totalCentis = Math.floor(safeMs / 10); + const totalSeconds = Math.floor(totalCentis / 100); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, '0')}`; + const centis = totalCentis % 100; + const pad = (value: number) => value.toString().padStart(2, '0'); + return `${pad(minutes)}:${pad(seconds)}:${pad(centis)}`; } function verseReference(bookName: string, chapter: number, verse: number) { diff --git a/src/hooks/useRecorder.test.ts b/src/hooks/useRecorder.test.ts index 95f3971..0c3a386 100644 --- a/src/hooks/useRecorder.test.ts +++ b/src/hooks/useRecorder.test.ts @@ -131,51 +131,62 @@ describe('useRecorder', () => { expect(result.current.status).toBe('recording'); }); - it('flushes to KV on pause and clears it on stop', async () => { - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); - await waitReady(result); - - await act(async () => { - await result.current.start(); - }); - - mockRecorder.currentTime = 2.5; - - await act(async () => { - await result.current.pause(); - }); - - expect(mockRecorder.pause).toHaveBeenCalled(); - expect(mockSetPausedTake).toHaveBeenCalledWith( - expect.objectContaining({ - bibleTextId: 42, - fileUri: 'file:///tmp/take-1.m4a', - elapsedMs: 2500, - }), - ); - expect(result.current.status).toBe('paused'); - - // resume back to recording; then stop and commit - mockRecorder.currentTime = 0; - await act(async () => { - await result.current.resume(); - }); - expect(result.current.status).toBe('recording'); - - mockRecorder.currentTime = 3; - await act(async () => { - await result.current.stop(); - }); - - expect(mockRecorder.stop).toHaveBeenCalled(); - expect(mockInsertRecording).toHaveBeenCalledWith( - expect.objectContaining({ - bibleTextId: 42, - localFilePath: 'file:///tmp/take-1.m4a', - }), - ); - expect(mockClearPausedTake).toHaveBeenCalledWith(42); - expect(result.current.status).toBe('review'); + it('accumulates only active recording time across pause and resume', async () => { + jest.useFakeTimers(); + + try { + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + + // Anchor after mount so `waitFor`'s internal poll drift doesn't skew t0. + jest.setSystemTime(new Date('2026-07-01T00:00:00.000Z')); + await act(async () => { + await result.current.start(); + }); + + jest.setSystemTime(new Date('2026-07-01T00:00:02.500Z')); + await act(async () => { + await result.current.pause(); + }); + + expect(mockRecorder.pause).toHaveBeenCalled(); + expect(mockSetPausedTake).toHaveBeenCalledWith( + expect.objectContaining({ + bibleTextId: 42, + fileUri: 'file:///tmp/take-1.m4a', + elapsedMs: 2500, + }), + ); + expect(result.current.status).toBe('paused'); + expect(result.current.elapsedMs).toBe(2500); + + // Long pause window: elapsed stays put; resume starts a new active segment. + jest.setSystemTime(new Date('2026-07-01T00:00:12.500Z')); + await act(async () => { + await result.current.resume(); + }); + expect(result.current.status).toBe('recording'); + expect(result.current.elapsedMs).toBe(2500); + + // Record for another 3s and stop; total active time = 5.5s, not 15.5s. + jest.setSystemTime(new Date('2026-07-01T00:00:15.500Z')); + await act(async () => { + await result.current.stop(); + }); + + expect(mockRecorder.stop).toHaveBeenCalled(); + expect(mockInsertRecording).toHaveBeenCalledWith( + expect.objectContaining({ + bibleTextId: 42, + localFilePath: 'file:///tmp/take-1.m4a', + durationMs: 5500, + }), + ); + expect(mockClearPausedTake).toHaveBeenCalledWith(42); + expect(result.current.status).toBe('review'); + } finally { + jest.useRealTimers(); + } }); it('re-record starts a new recording and stop overwrites the previous draft', async () => { diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts index e5602dd..08e2c93 100644 --- a/src/hooks/useRecorder.ts +++ b/src/hooks/useRecorder.ts @@ -64,7 +64,9 @@ export interface UseRecorderApi { discardPaused: () => Promise; } -const TICK_INTERVAL_MS = 200; +// 50ms tick gives ~20fps on the centiseconds portion of the duration display +// (`MM:SS:HH`) without hammering the React state. +const TICK_INTERVAL_MS = 50; interface PermissionLike { granted: boolean; @@ -90,7 +92,11 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { const tickRef = useRef | null>(null); const startedAtRef = useRef(null); + // `baseElapsedRef` accumulates the recorded milliseconds of all previous + // active segments (i.e. everything up to the most recent pause). The current + // segment's contribution is measured by wall clock via `runningSinceRef`. const baseElapsedRef = useRef(0); + const runningSinceRef = useRef(null); const bibleTextIdRef = useRef(bibleTextId); bibleTextIdRef.current = bibleTextId; @@ -105,11 +111,11 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { const startTicking = useCallback(() => { clearTick(); tickRef.current = setInterval(() => { - setElapsedMs( - baseElapsedRef.current + Math.round(recorder.currentTime * 1000), - ); + const startedAt = runningSinceRef.current; + if (startedAt === null) return; + setElapsedMs(baseElapsedRef.current + (Date.now() - startedAt)); }, TICK_INTERVAL_MS); - }, [clearTick, recorder]); + }, [clearTick]); // Load latest DB recording for the current verse and reset state. useEffect(() => { @@ -119,6 +125,7 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { setIsReady(false); setElapsedMs(0); baseElapsedRef.current = 0; + runningSinceRef.current = null; startedAtRef.current = null; clearTick(); @@ -221,6 +228,7 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true }); await recorder.prepareToRecordAsync(); baseElapsedRef.current = 0; + runningSinceRef.current = Date.now(); startedAtRef.current = new Date().toISOString(); setElapsedMs(0); recorder.record(); @@ -243,11 +251,14 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { if (currentBibleTextId === null) return; if (status !== 'recording') return; - const finalElapsed = - baseElapsedRef.current + Math.round(recorder.currentTime * 1000); + const segmentStart = runningSinceRef.current; + const segmentElapsed = + segmentStart === null ? 0 : Date.now() - segmentStart; + const finalElapsed = baseElapsedRef.current + segmentElapsed; recorder.pause(); clearTick(); baseElapsedRef.current = finalElapsed; + runningSinceRef.current = null; setElapsedMs(finalElapsed); setStatus('paused'); persistPausedMarker(recorder, finalElapsed); @@ -260,6 +271,7 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { const resume = useCallback(async () => { if (status !== 'paused') return; recorder.record(); + runningSinceRef.current = Date.now(); setStatus('recording'); startTicking(); }, [recorder, startTicking, status]); @@ -267,10 +279,17 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { const commitRecording = useCallback(async () => { const currentBibleTextId = bibleTextIdRef.current; if (currentBibleTextId === null) return; + + // Compute the final duration from refs before stopping so a late tick + // or a stale `elapsedMs` render can't skew what lands in the DB. + const segmentStart = runningSinceRef.current; + const activeSegmentMs = + segmentStart === null ? 0 : Date.now() - segmentStart; + const duration = baseElapsedRef.current + activeSegmentMs; + await recorder.stop(); clearTick(); - const duration = elapsedMs; const fileUri = recorder.uri; if (!fileUri) { log.error('Recorder returned no URI on stop; skipping DB write'); @@ -287,10 +306,12 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { clearPausedTake(currentBibleTextId); baseElapsedRef.current = 0; + runningSinceRef.current = null; startedAtRef.current = null; + setElapsedMs(duration); setCurrentRecording(inserted); setStatus('review'); - }, [clearTick, currentRecording, elapsedMs, recorder]); + }, [clearTick, currentRecording, recorder]); const stop = useCallback(async () => { if (status !== 'recording' && status !== 'paused') return; @@ -313,6 +334,7 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { setStatus('idle'); setElapsedMs(0); baseElapsedRef.current = 0; + runningSinceRef.current = null; }, [currentRecording, status]); const discardPaused = useCallback(async () => { @@ -326,6 +348,7 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { clearPausedTake(currentBibleTextId); clearTick(); baseElapsedRef.current = 0; + runningSinceRef.current = null; startedAtRef.current = null; setElapsedMs(0); setStatus(currentRecording ? 'review' : 'idle'); From 8d5dd405a09c322b0e5d3f2541d64004a6bf58e8 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 11:27:43 -0300 Subject: [PATCH 10/55] [#49]: Tidy verse-nav spacing and drop dead waveform styles Switch the verse-nav row to justifyContent: 'space-around' so the chevrons and reference sit with balanced margins instead of hugging the edges. Delete the two commented-out waveform style lines that were left behind during recording-state polish. Refs #49 Co-authored-by: Cursor --- src/app/tabs/drafting/RecordTab.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/tabs/drafting/RecordTab.tsx b/src/app/tabs/drafting/RecordTab.tsx index f910fa8..e13fa39 100644 --- a/src/app/tabs/drafting/RecordTab.tsx +++ b/src/app/tabs/drafting/RecordTab.tsx @@ -546,7 +546,7 @@ const styles = StyleSheet.create({ verseNav: { flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', + justifyContent: 'space-around', }, verseNavButton: { padding: theme.spacing.sm, @@ -565,9 +565,7 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', height: 72, width: '50%', - // alignContent: 'center', alignSelf: 'center', - // paddingHorizontal: theme.spacing.xs, }, waveformBarLive: { width: 5, From c21116ae5239db1203560a54cb1b2401086400b3 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 12:16:33 -0300 Subject: [PATCH 11/55] [#49]: Extract draft playback into a dedicated useDraftPlayback hook Split the player concern out of useRecorder into useDraftPlayback, which takes only the take's file URI and owns the playback-side audio routing and rewind-on-finish. useRecorder composes it with a one-way dependency and stops playback explicitly before re-record/delete, keeping its public API unchanged. Refs #49 Co-authored-by: Cursor --- src/hooks/useDraftPlayback.test.ts | 90 +++++++++++++++++++++ src/hooks/useDraftPlayback.ts | 70 ++++++++++++++++ src/hooks/useRecorder.test.ts | 124 ++++++++++++++++++++++++++++- src/hooks/useRecorder.ts | 33 +++++++- 4 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 src/hooks/useDraftPlayback.test.ts create mode 100644 src/hooks/useDraftPlayback.ts diff --git a/src/hooks/useDraftPlayback.test.ts b/src/hooks/useDraftPlayback.test.ts new file mode 100644 index 0000000..5c242ca --- /dev/null +++ b/src/hooks/useDraftPlayback.test.ts @@ -0,0 +1,90 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { useDraftPlayback } from './useDraftPlayback'; + +const mockPlayer = { + play: jest.fn(), + pause: jest.fn(), + seekTo: jest.fn().mockResolvedValue(undefined), + currentTime: 0, + duration: 0, +}; +let mockPlayerStatus: { playing?: boolean; didJustFinish?: boolean } = { + playing: false, + didJustFinish: false, +}; +const mockSetAudioModeAsync = jest.fn().mockResolvedValue(undefined); + +jest.mock('expo-audio', () => ({ + useAudioPlayer: () => mockPlayer, + useAudioPlayerStatus: () => mockPlayerStatus, + setAudioModeAsync: (mode: unknown) => mockSetAudioModeAsync(mode), +})); + +describe('useDraftPlayback', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPlayerStatus = { playing: false, didJustFinish: false }; + }); + + it('reports not playing and does nothing on toggle when there is no source', async () => { + const { result } = renderHook(() => useDraftPlayback(null)); + expect(result.current.isPlaying).toBe(false); + + await act(async () => { + await result.current.toggle(); + }); + + expect(mockPlayer.play).not.toHaveBeenCalled(); + expect(mockSetAudioModeAsync).not.toHaveBeenCalled(); + }); + + it('routes audio to the speaker and plays on toggle when idle', async () => { + const { result } = renderHook(() => useDraftPlayback('/tmp/take-1.m4a')); + + await act(async () => { + await result.current.toggle(); + }); + + expect(mockSetAudioModeAsync).toHaveBeenCalledWith( + expect.objectContaining({ allowsRecording: false }), + ); + expect(mockPlayer.play).toHaveBeenCalledTimes(1); + expect(mockPlayer.pause).not.toHaveBeenCalled(); + }); + + it('pauses on toggle when already playing', async () => { + mockPlayerStatus = { playing: true, didJustFinish: false }; + const { result } = renderHook(() => useDraftPlayback('/tmp/take-1.m4a')); + expect(result.current.isPlaying).toBe(true); + + await act(async () => { + await result.current.toggle(); + }); + + expect(mockPlayer.pause).toHaveBeenCalledTimes(1); + expect(mockPlayer.play).not.toHaveBeenCalled(); + }); + + it('stop() pauses while playing', () => { + mockPlayerStatus = { playing: true, didJustFinish: false }; + const { result } = renderHook(() => useDraftPlayback('/tmp/take-1.m4a')); + + act(() => result.current.stop()); + expect(mockPlayer.pause).toHaveBeenCalledTimes(1); + }); + + it('stop() is a no-op when nothing is playing', () => { + mockPlayerStatus = { playing: false, didJustFinish: false }; + const { result } = renderHook(() => useDraftPlayback('/tmp/take-1.m4a')); + + act(() => result.current.stop()); + expect(mockPlayer.pause).not.toHaveBeenCalled(); + }); + + it('rewinds to the start when a take finishes playing', async () => { + mockPlayerStatus = { playing: false, didJustFinish: true }; + renderHook(() => useDraftPlayback('/tmp/take-1.m4a')); + + await waitFor(() => expect(mockPlayer.seekTo).toHaveBeenCalledWith(0)); + }); +}); diff --git a/src/hooks/useDraftPlayback.ts b/src/hooks/useDraftPlayback.ts new file mode 100644 index 0000000..55af53e --- /dev/null +++ b/src/hooks/useDraftPlayback.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { + setAudioModeAsync, + useAudioPlayer, + useAudioPlayerStatus, +} from 'expo-audio'; +import type { AudioSource } from 'expo-audio'; +import { logger } from '../utils/logger'; + +const log = logger.create('useDraftPlayback'); + +export interface UseDraftPlaybackApi { + isPlaying: boolean; + toggle: () => Promise; + stop: () => void; +} + +/** + * Owns playback of a recorded draft take. This hook is intentionally decoupled + * from the recorder state machine: it only needs the take's file URI, so it can + * be tested and reused independently of recording. + * + * Audio-session ownership is split by concern — this hook owns the *playback* + * side (`allowsRecording: false`, routing through the speaker rather than the + * earpiece used while capturing); the recorder owns the recording side. Callers + * that transition back into recording must re-assert the recording audio mode. + * + * `source === null` keeps the player idle when there is no draft to review. + */ +export function useDraftPlayback(source: string | null): UseDraftPlaybackApi { + // Recreate the player whenever the reviewed take changes; `useAudioPlayer` + // releases the previous instance for us. + const playbackSource = useMemo( + () => (source ? { uri: source } : null), + [source], + ); + const player = useAudioPlayer(playbackSource); + const playerStatus = useAudioPlayerStatus(player); + const isPlaying = playerStatus?.playing ?? false; + + const stop = useCallback(() => { + if (playerStatus?.playing) player.pause(); + }, [player, playerStatus]); + + const toggle = useCallback(async () => { + if (!source) return; + if (playerStatus?.playing) { + player.pause(); + return; + } + // Route playback out of the recording session so it plays through the + // speaker rather than the earpiece used while capturing. + await setAudioModeAsync({ + allowsRecording: false, + playsInSilentMode: true, + }); + player.play(); + }, [player, playerStatus, source]); + + // Rewind to the start once a take finishes so the next tap replays it whole. + useEffect(() => { + if (playerStatus?.didJustFinish) { + player.seekTo(0).catch(error => { + log.warn('Failed to rewind finished playback', { error }); + }); + } + }, [player, playerStatus?.didJustFinish]); + + return { isPlaying, toggle, stop }; +} diff --git a/src/hooks/useRecorder.test.ts b/src/hooks/useRecorder.test.ts index 0c3a386..c744f82 100644 --- a/src/hooks/useRecorder.test.ts +++ b/src/hooks/useRecorder.test.ts @@ -18,6 +18,18 @@ const mockGetPausedTake = jest.fn().mockReturnValue(null); const mockSetPausedTake = jest.fn(); const mockClearPausedTake = jest.fn(); +const mockPlayer = { + play: jest.fn(), + pause: jest.fn(), + seekTo: jest.fn().mockResolvedValue(undefined), + currentTime: 0, + duration: 0, +}; +let mockPlayerStatus: { playing?: boolean; didJustFinish?: boolean } = { + playing: false, + didJustFinish: false, +}; + jest.mock('expo-crypto', () => ({ randomUUID: jest.fn(() => 'test-uuid-1'), })); @@ -25,8 +37,8 @@ jest.mock('expo-crypto', () => ({ jest.mock('expo-audio', () => ({ RecordingPresets: { HIGH_QUALITY: {} }, useAudioRecorder: () => mockRecorder, - useAudioPlayer: () => ({ play: jest.fn(), pause: jest.fn() }), - useAudioPlayerStatus: () => ({}), + useAudioPlayer: () => mockPlayer, + useAudioPlayerStatus: () => mockPlayerStatus, requestRecordingPermissionsAsync: jest.fn().mockResolvedValue({ granted: true, canAskAgain: true, @@ -65,6 +77,9 @@ describe('useRecorder', () => { jest.clearAllMocks(); mockRecorder.currentTime = 0; mockRecorder.uri = 'file:///tmp/take-1.m4a'; + mockPlayer.currentTime = 0; + mockPlayer.duration = 0; + mockPlayerStatus = { playing: false, didJustFinish: false }; mockGetLatestRecordingForVerse.mockResolvedValue(null); mockGetPausedTake.mockReturnValue(null); mockInsertRecording.mockImplementation(async input => ({ @@ -289,6 +304,111 @@ describe('useRecorder', () => { expect(result.current.permission).toBe('blocked'); }); + it('plays the current draft on togglePlayback from review', async () => { + mockGetLatestRecordingForVerse.mockResolvedValueOnce({ + id: 'existing', + bibleTextId: 42, + localFilePath: '/tmp/existing.m4a', + takeNumber: 1, + isLatest: true, + syncStatus: 'pending', + createdAt: 'x', + updatedAt: 'x', + }); + + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + expect(result.current.status).toBe('review'); + expect(result.current.isPlaying).toBe(false); + + await act(async () => { + await result.current.togglePlayback(); + }); + + expect(mockPlayer.play).toHaveBeenCalledTimes(1); + expect(mockPlayer.pause).not.toHaveBeenCalled(); + }); + + it('pauses playback when toggled while already playing', async () => { + mockGetLatestRecordingForVerse.mockResolvedValueOnce({ + id: 'existing', + bibleTextId: 42, + localFilePath: '/tmp/existing.m4a', + takeNumber: 1, + isLatest: true, + syncStatus: 'pending', + createdAt: 'x', + updatedAt: 'x', + }); + mockPlayerStatus = { playing: true, didJustFinish: false }; + + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + expect(result.current.isPlaying).toBe(true); + + await act(async () => { + await result.current.togglePlayback(); + }); + + expect(mockPlayer.pause).toHaveBeenCalledTimes(1); + expect(mockPlayer.play).not.toHaveBeenCalled(); + }); + + it('does not play when there is no draft to review', async () => { + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + expect(result.current.status).toBe('idle'); + + await act(async () => { + await result.current.togglePlayback(); + }); + + expect(mockPlayer.play).not.toHaveBeenCalled(); + }); + + it('rewinds to the start when a take finishes playing', async () => { + mockGetLatestRecordingForVerse.mockResolvedValueOnce({ + id: 'existing', + bibleTextId: 42, + localFilePath: '/tmp/existing.m4a', + takeNumber: 1, + isLatest: true, + syncStatus: 'pending', + createdAt: 'x', + updatedAt: 'x', + }); + mockPlayerStatus = { playing: false, didJustFinish: true }; + + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + + await waitFor(() => expect(mockPlayer.seekTo).toHaveBeenCalledWith(0)); + }); + + it('stops playback before re-recording an existing draft', async () => { + mockGetLatestRecordingForVerse.mockResolvedValueOnce({ + id: 'existing', + bibleTextId: 42, + localFilePath: '/tmp/existing.m4a', + takeNumber: 1, + isLatest: true, + syncStatus: 'pending', + createdAt: 'x', + updatedAt: 'x', + }); + mockPlayerStatus = { playing: true, didJustFinish: false }; + + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + + await act(async () => { + await result.current.reRecord(); + }); + + expect(mockPlayer.pause).toHaveBeenCalled(); + expect(mockRecorder.record).toHaveBeenCalled(); + }); + it('auto-pauses when the app is backgrounded during recording', async () => { const listeners: Array<(state: string) => void> = []; const addSpy = jest diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts index 08e2c93..807bf2e 100644 --- a/src/hooks/useRecorder.ts +++ b/src/hooks/useRecorder.ts @@ -18,6 +18,7 @@ import { } from '../services/storage'; import type { Recording } from '../types/db/types'; import { logger } from '../utils/logger'; +import { useDraftPlayback } from './useDraftPlayback'; const log = logger.create('useRecorder'); @@ -53,6 +54,7 @@ export interface UseRecorderApi { permission: PermissionState; currentRecording: Recording | null; isReady: boolean; + isPlaying: boolean; requestPermission: () => Promise; start: () => Promise; @@ -62,6 +64,8 @@ export interface UseRecorderApi { reRecord: () => Promise; deleteCurrent: () => Promise; discardPaused: () => Promise; + togglePlayback: () => Promise; + stopPlayback: () => void; } // 50ms tick gives ~20fps on the centiseconds portion of the duration display @@ -90,6 +94,15 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { ); const [isReady, setIsReady] = useState(false); + // Playback is a separate concern with a one-way dependency: it takes the + // reviewed take's URI and knows nothing about the recorder. The recorder + // orchestrates it explicitly (stopping playback before re-record/delete). + const { + isPlaying, + toggle: togglePlaybackInternal, + stop: stopPlayback, + } = useDraftPlayback(currentRecording?.localFilePath ?? null); + const tickRef = useRef | null>(null); const startedAtRef = useRef(null); // `baseElapsedRef` accumulates the recorded milliseconds of all previous @@ -320,22 +333,30 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { const reRecord = useCallback(async () => { if (status !== 'review') return; + stopPlayback(); if (permission !== 'granted') { const { granted } = await requestPermission(); if (!granted) return; } await startRecordingSession(); - }, [permission, requestPermission, startRecordingSession, status]); + }, [ + permission, + requestPermission, + startRecordingSession, + status, + stopPlayback, + ]); const deleteCurrent = useCallback(async () => { if (status !== 'review' || !currentRecording) return; + stopPlayback(); await deleteRecordingById(currentRecording.id); setCurrentRecording(null); setStatus('idle'); setElapsedMs(0); baseElapsedRef.current = 0; runningSinceRef.current = null; - }, [currentRecording, status]); + }, [currentRecording, status, stopPlayback]); const discardPaused = useCallback(async () => { const currentBibleTextId = bibleTextIdRef.current; @@ -354,12 +375,18 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { setStatus(currentRecording ? 'review' : 'idle'); }, [clearTick, currentRecording, recorder]); + const togglePlayback = useCallback(async () => { + if (status !== 'review' || !currentRecording) return; + await togglePlaybackInternal(); + }, [currentRecording, status, togglePlaybackInternal]); + return { status, elapsedMs, permission, currentRecording, isReady, + isPlaying, requestPermission, start, pause, @@ -368,5 +395,7 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { reRecord, deleteCurrent, discardPaused, + togglePlayback, + stopPlayback, }; } From 595896fb2a3eb984bbca7833f9245d11ab1b90d5 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 12:16:44 -0300 Subject: [PATCH 12/55] [#49]: Wire draft playback into the Record tab review controls Hook the review play button up to togglePlayback with a play/pause affordance, and stop playback when navigating between verses. Refs #49 Co-authored-by: Cursor --- src/app/tabs/drafting/RecordTab.test.tsx | 63 ++++++++++++++++++++---- src/app/tabs/drafting/RecordTab.tsx | 28 ++++++++--- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/app/tabs/drafting/RecordTab.test.tsx b/src/app/tabs/drafting/RecordTab.test.tsx index 95771ff..7eb397e 100644 --- a/src/app/tabs/drafting/RecordTab.test.tsx +++ b/src/app/tabs/drafting/RecordTab.test.tsx @@ -36,6 +36,7 @@ function baseRecorderState() { permission: 'granted' as const, currentRecording: null, isReady: true, + isPlaying: false, requestPermission: jest .fn() .mockResolvedValue({ granted: true, canAskAgain: true }), @@ -46,6 +47,21 @@ function baseRecorderState() { reRecord: jest.fn().mockResolvedValue(undefined), deleteCurrent: jest.fn().mockResolvedValue(undefined), discardPaused: jest.fn().mockResolvedValue(undefined), + togglePlayback: jest.fn().mockResolvedValue(undefined), + stopPlayback: jest.fn(), + }; +} + +function reviewRecording() { + return { + id: 'rec-1', + bibleTextId: 42, + localFilePath: '/tmp/rec-1.m4a', + takeNumber: 1, + isLatest: true, + syncStatus: 'pending' as const, + createdAt: 'x', + updatedAt: 'x', }; } @@ -129,16 +145,7 @@ describe('RecordTab', () => { const state = { ...baseRecorderState(), status: 'review' as const, - currentRecording: { - id: 'rec-1', - bibleTextId: 42, - localFilePath: '/tmp/rec-1.m4a', - takeNumber: 1, - isLatest: true, - syncStatus: 'pending' as const, - createdAt: 'x', - updatedAt: 'x', - }, + currentRecording: reviewRecording(), }; mockUseRecorder.mockReturnValue(state); @@ -167,6 +174,42 @@ describe('RecordTab', () => { alertSpy.mockRestore(); }); + it('plays the draft when the review play button is pressed', () => { + const togglePlayback = jest.fn().mockResolvedValue(undefined); + mockUseRecorder.mockReturnValue({ + ...baseRecorderState(), + status: 'review', + currentRecording: reviewRecording(), + isPlaying: false, + togglePlayback, + }); + + renderTab(); + + expect( + screen.getByTestId('record-play-button').props.accessibilityLabel, + ).toBe('Play draft'); + fireEvent.press(screen.getByTestId('record-play-button')); + expect(togglePlayback).toHaveBeenCalledTimes(1); + }); + + it('shows a pause affordance while the draft is playing', () => { + mockUseRecorder.mockReturnValue({ + ...baseRecorderState(), + status: 'review', + currentRecording: reviewRecording(), + isPlaying: true, + }); + + renderTab(); + + const playButton = screen.getByTestId('record-play-button'); + expect(playButton.props.accessibilityLabel).toBe('Pause draft'); + expect(playButton.props.accessibilityState).toEqual( + expect.objectContaining({ selected: true }), + ); + }); + it('disables verse chevrons while recording even when navigation is possible', () => { mockUseRecorder.mockReturnValue({ ...baseRecorderState(), diff --git a/src/app/tabs/drafting/RecordTab.tsx b/src/app/tabs/drafting/RecordTab.tsx index e13fa39..4f07879 100644 --- a/src/app/tabs/drafting/RecordTab.tsx +++ b/src/app/tabs/drafting/RecordTab.tsx @@ -158,11 +158,13 @@ export function RecordTab({ function handlePrev() { if (prevDisabled) return; + recorder.stopPlayback(); withPausedGuard(() => onSelectVerse(verses[verseIndex - 1]!.verseNumber)); } function handleNext() { if (nextDisabled) return; + recorder.stopPlayback(); withPausedGuard(() => onSelectVerse(verses[verseIndex + 1]!.verseNumber)); } @@ -454,18 +456,28 @@ export function RecordTab({ { - // Playback is stubbed until #47 pipes the chapter audio player. - // Individual take playback still runs via expo-audio in future work. + recorder.togglePlayback(); }} accessibilityRole="button" - accessibilityLabel="Play draft" + accessibilityLabel={ + recorder.isPlaying ? 'Pause draft' : 'Play draft' + } + accessibilityState={{ selected: recorder.isPlaying }} testID="record-play-button" > - + {recorder.isPlaying ? ( + + ) : ( + + )} From cc07aeb2a2421c062572a62dff5609de3195e290 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 13:55:38 -0300 Subject: [PATCH 13/55] [#49]: Add expo-file-system for durable recording storage Recordings need to move out of the evictable cache into the document directory and be read back at absolute paths, which requires the expo-file-system File/Directory/Paths APIs. Refs #49 Co-authored-by: Cursor --- package-lock.json | 21 +++++++++++---------- package.json | 1 + 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4603695..7f0c8b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "expo-build-properties": "~56.0.20", "expo-crypto": "~56.0.4", "expo-dev-client": "~56.0.21", + "expo-file-system": "~56.0.8", "expo-secure-store": "~56.0.4", "expo-updates": "~56.0.20", "lucide-react-native": "^0.544.0", @@ -8260,6 +8261,16 @@ "integrity": "sha512-r8h0ZIExacCrSRgY+ARfhMvFqosLHLJt1L7jyhvabfr1DN/ZDKDsYbovss2tzkpEUZGxZ3BPcB5epCwUsBBdOA==", "license": "MIT" }, + "node_modules/expo-file-system": { + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.8.tgz", + "integrity": "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-json-utils": { "version": "56.0.0", "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-56.0.0.tgz", @@ -8783,16 +8794,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "56.0.8", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.8.tgz", - "integrity": "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-font": { "version": "56.0.7", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.7.tgz", diff --git a/package.json b/package.json index c7f2eb0..e8e7174 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "expo-build-properties": "~56.0.20", "expo-crypto": "~56.0.4", "expo-dev-client": "~56.0.21", + "expo-file-system": "~56.0.8", "expo-secure-store": "~56.0.4", "expo-updates": "~56.0.20", "lucide-react-native": "^0.544.0", From 56581b511333e6f6bb194158fc273834ee3cc43b Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 13:55:48 -0300 Subject: [PATCH 14/55] [#49]: Add durable recording storage layout and file helpers Introduce recordingStorage with a user/project/book/chapter/verse key layout rooted at the document directory. Persist only relative keys and resolve absolute uris at read time so paths survive reinstalls, plus best-effort move/delete helpers for takes and per-user cleanup. Refs #49 Co-authored-by: Cursor --- src/services/recordingStorage.test.ts | 164 ++++++++++++++++++++++++++ src/services/recordingStorage.ts | 139 ++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 src/services/recordingStorage.test.ts create mode 100644 src/services/recordingStorage.ts diff --git a/src/services/recordingStorage.test.ts b/src/services/recordingStorage.test.ts new file mode 100644 index 0000000..e78fa44 --- /dev/null +++ b/src/services/recordingStorage.test.ts @@ -0,0 +1,164 @@ +import { + buildRecordingKey, + deleteRecordingFile, + extensionFromUri, + moveIntoStore, + recordingKeySegments, + resolveRecordingUri, +} from './recordingStorage'; + +const mockDocument = { uri: 'file:///docs' }; +const mockMove = jest.fn().mockResolvedValue(undefined); +const mockCreate = jest.fn(); +const mockDelete = jest.fn(); +let mockFileExists = true; +let mockFileSize = 4096; + +jest.mock('expo-file-system', () => ({ + Paths: { + get document() { + return mockDocument; + }, + }, + File: jest + .fn() + .mockImplementation((...uris: Array<{ uri: string } | string>) => ({ + uri: uris.map(u => (typeof u === 'string' ? u : u.uri)).join('/'), + move: mockMove, + delete: mockDelete, + get exists() { + return mockFileExists; + }, + get size() { + return mockFileSize; + }, + })), + Directory: jest + .fn() + .mockImplementation((...uris: Array<{ uri: string } | string>) => ({ + uri: uris.map(u => (typeof u === 'string' ? u : u.uri)).join('/'), + create: mockCreate, + delete: mockDelete, + get exists() { + return true; + }, + })), +})); + +const baseParts = { + userId: '123', + projectId: 45, + bookCode: 'gen', + chapterNumber: 1, + verseNumber: 7, + recordingId: 'rec-1', +}; + +describe('extensionFromUri', () => { + it('extracts a lowercase extension', () => { + expect(extensionFromUri('file:///tmp/take.M4A')).toBe('m4a'); + expect(extensionFromUri('file:///tmp/take.wav')).toBe('wav'); + }); + + it('ignores query strings and fragments', () => { + expect(extensionFromUri('file:///tmp/take.m4a?x=1')).toBe('m4a'); + }); + + it('falls back to m4a when there is no extension', () => { + expect(extensionFromUri('file:///tmp/take')).toBe('m4a'); + }); +}); + +describe('buildRecordingKey', () => { + it('builds a per-user/project/verse key with a UUID filename', () => { + expect(buildRecordingKey(baseParts)).toBe( + 'recordings/u123/p45/GEN/c001/v007/rec-1.m4a', + ); + }); + + it('pads chapter and verse and uppercases the book code', () => { + expect( + recordingKeySegments({ ...baseParts, chapterNumber: 12, verseNumber: 3 }), + ).toEqual([ + 'recordings', + 'u123', + 'p45', + 'GEN', + 'c012', + 'v003', + 'rec-1.m4a', + ]); + }); + + it('honors a provided extension', () => { + expect(buildRecordingKey({ ...baseParts, extension: 'wav' })).toBe( + 'recordings/u123/p45/GEN/c001/v007/rec-1.wav', + ); + }); + + it('sanitizes unsafe characters in segments', () => { + expect( + buildRecordingKey({ ...baseParts, userId: 'a/b c', bookCode: 'x!y' }), + ).toBe('recordings/ua_b_c/p45/X_Y/c001/v007/rec-1.m4a'); + }); +}); + +describe('resolveRecordingUri', () => { + it('joins relative keys against the document directory', () => { + expect( + resolveRecordingUri('recordings/u1/p2/GEN/c001/v001/a.m4a'), + ).toContain('recordings/u1/p2/GEN/c001/v001/a.m4a'); + expect(resolveRecordingUri('recordings/u1/a.m4a')).toContain( + 'file:///docs', + ); + }); + + it('returns already-absolute paths unchanged (legacy rows)', () => { + expect(resolveRecordingUri('file:///tmp/legacy.m4a')).toBe( + 'file:///tmp/legacy.m4a', + ); + expect(resolveRecordingUri('/tmp/legacy.m4a')).toBe('/tmp/legacy.m4a'); + }); +}); + +describe('moveIntoStore', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFileSize = 4096; + }); + + it('creates intermediate directories and moves the file, returning size', async () => { + const result = await moveIntoStore({ + sourceUri: 'file:///cache/take.m4a', + key: 'recordings/u1/p2/GEN/c001/v001/rec-1.m4a', + }); + + expect(mockCreate).toHaveBeenCalledWith({ + intermediates: true, + idempotent: true, + }); + expect(mockMove).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + key: 'recordings/u1/p2/GEN/c001/v001/rec-1.m4a', + sizeBytes: 4096, + }); + }); +}); + +describe('deleteRecordingFile', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFileExists = true; + }); + + it('deletes an existing file', () => { + deleteRecordingFile('recordings/u1/p2/GEN/c001/v001/rec-1.m4a'); + expect(mockDelete).toHaveBeenCalledTimes(1); + }); + + it('does nothing when the file is missing', () => { + mockFileExists = false; + deleteRecordingFile('recordings/u1/p2/GEN/c001/v001/missing.m4a'); + expect(mockDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/recordingStorage.ts b/src/services/recordingStorage.ts new file mode 100644 index 0000000..c053c48 --- /dev/null +++ b/src/services/recordingStorage.ts @@ -0,0 +1,139 @@ +import { Directory, File, Paths } from 'expo-file-system'; +import { logger } from '../utils/logger'; + +const log = logger.create('recordingStorage'); + +/** + * Durable on-disk layout for recordings, rooted at `Paths.document`: + * + * recordings/u/p//c/v/. + * + * The filename is the recording's UUID PK, so it is globally unique and never + * needs renaming; multiple takes for a verse simply add files. The directory + * tree partitions by user (isolation + single-directory wipe on logout) then by + * the domain hierarchy (project -> book -> chapter -> verse) for browsability. + * + * Only a RELATIVE key (the part after `Paths.document`) is persisted in the DB; + * `resolveRecordingUri` rebuilds the absolute uri at read time because the + * document directory can change across reinstalls/updates. + */ +export const RECORDINGS_ROOT = 'recordings'; +const DEFAULT_EXTENSION = 'm4a'; + +export interface RecordingKeyParts { + userId: string; + projectId: number; + bookCode: string; + chapterNumber: number; + verseNumber: number; + recordingId: string; + extension?: string; +} + +export interface MoveIntoStoreArgs { + sourceUri: string; + key: string; +} + +export interface MoveIntoStoreResult { + key: string; + sizeBytes: number | null; +} + +function pad3(value: number): string { + return String(value).padStart(3, '0'); +} + +/** Keeps path segments filesystem-safe without collapsing distinct values. */ +function sanitizeSegment(value: string): string { + return value.replace(/[^a-zA-Z0-9._-]/g, '_'); +} + +/** Extracts a lowercase file extension from a uri, falling back to `.m4a`. */ +export function extensionFromUri( + uri: string, + fallback: string = DEFAULT_EXTENSION, +): string { + const match = /\.([a-zA-Z0-9]+)(?:[?#].*)?$/.exec(uri); + return match ? match[1]!.toLowerCase() : fallback; +} + +/** Ordered path segments (relative to the document directory) for a recording. */ +export function recordingKeySegments(parts: RecordingKeyParts): string[] { + return [ + RECORDINGS_ROOT, + `u${sanitizeSegment(parts.userId || 'unknown')}`, + `p${parts.projectId}`, + sanitizeSegment(parts.bookCode || 'UNK').toUpperCase(), + `c${pad3(parts.chapterNumber)}`, + `v${pad3(parts.verseNumber)}`, + `${sanitizeSegment(parts.recordingId)}.${ + parts.extension ?? DEFAULT_EXTENSION + }`, + ]; +} + +/** Relative storage key persisted in `recordings.local_file_path`. */ +export function buildRecordingKey(parts: RecordingKeyParts): string { + return recordingKeySegments(parts).join('/'); +} + +/** + * Resolves a stored value to an absolute uri for playback/upload. Relative keys + * are joined against the document directory; already-absolute paths (legacy rows + * written before durable storage) are returned unchanged. + */ +export function resolveRecordingUri(pathOrKey: string): string { + if (pathOrKey.startsWith('file:') || pathOrKey.startsWith('/')) { + return pathOrKey; + } + return new File(Paths.document, ...pathOrKey.split('/')).uri; +} + +/** + * Moves a freshly recorded file out of the (evictable) cache into the durable + * document tree at `key`, creating intermediate directories as needed. Returns + * the final key and the file size once settled. + */ +export async function moveIntoStore({ + sourceUri, + key, +}: MoveIntoStoreArgs): Promise { + const segments = key.split('/'); + const fileName = segments[segments.length - 1]!; + const dirSegments = segments.slice(0, -1); + + const destDir = new Directory(Paths.document, ...dirSegments); + destDir.create({ intermediates: true, idempotent: true }); + + const source = new File(sourceUri); + const dest = new File(destDir, fileName); + await source.move(dest); + + const size = dest.size; + return { key, sizeBytes: typeof size === 'number' ? size : null }; +} + +/** Best-effort unlink of a stored recording file. Never throws. */ +export function deleteRecordingFile(pathOrKey: string): void { + try { + const file = new File(resolveRecordingUri(pathOrKey)); + if (file.exists) file.delete(); + } catch (error) { + log.warn('Failed to delete recording file', { pathOrKey, error }); + } +} + +/** Best-effort removal of a user's entire recordings subtree (e.g. on logout). */ +export function deleteUserTree(userId: string): void { + try { + const dir = new Directory( + Paths.document, + RECORDINGS_ROOT, + `u${sanitizeSegment(userId || 'unknown')}`, + ); + if (dir.exists) dir.delete(); + } catch (error) { + log.warn('Failed to delete user recordings tree', { userId, error }); + } +} From 68d3b29e264d54ac0056abdb7e33893f25d9270e Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 13:55:58 -0300 Subject: [PATCH 15/55] [#49]: Persist recording attribution and relative storage keys Add user_id and chapter_assignment_id columns (with migrations for existing DBs), thread them through the Recording types, inserts and verse queries, and resolve a project id for a project unit to build storage keys. deleteRecordingById now also unlinks the durable file. Refs #49 Co-authored-by: Cursor --- src/db/index.ts | 7 ++++- src/db/queries.ts | 26 +++++++++++++++--- src/db/repository.recordings.test.ts | 40 ++++++++++++++++++++++++---- src/db/repository.ts | 25 ++++++++++++++--- src/db/schema.ts | 8 ++++++ src/types/db/types.ts | 4 +++ 6 files changed, 97 insertions(+), 13 deletions(-) diff --git a/src/db/index.ts b/src/db/index.ts index 6ef6010..8616f23 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,5 +1,9 @@ import { open, DB } from '@op-engineering/op-sqlite'; -import { chapterAssignmentMigrations, createTableQueries } from './schema'; +import { + chapterAssignmentMigrations, + createTableQueries, + recordingMigrations, +} from './schema'; import { logger } from '../utils/logger'; import { setDatabase, getDatabase } from './db'; @@ -66,6 +70,7 @@ export async function initializeDatabase(): Promise { await runStatements(db, createTableQueries, 'table'); await runStatements(db, chapterAssignmentMigrations, 'column'); + await runStatements(db, recordingMigrations, 'column'); log.info('All tables created successfully'); } catch (error: unknown) { diff --git a/src/db/queries.ts b/src/db/queries.ts index c2885e7..45b66aa 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -380,6 +380,24 @@ export async function getPendingUploadCount(): Promise { } } +/** Resolves the owning `projects.id` for a project unit (for recording paths). */ +export async function getProjectIdForProjectUnit( + projectUnitId: number, +): Promise { + const db = getDatabase(); + try { + const result = await db.execute( + `SELECT project_id FROM project_units WHERE id = ? LIMIT 1`, + [projectUnitId], + ); + const row = (result?.rows as unknown as { project_id: number }[])?.[0]; + return row?.project_id ?? null; + } catch (error) { + log.error('Error resolving project id for project unit', { error }); + return null; + } +} + /** Resolves the `bible_texts.id` PK a recording joins against. */ export async function getBibleTextId( bibleId: number, @@ -409,9 +427,9 @@ export async function getLatestRecordingForVerse( const db = getDatabase(); try { const result = await db.execute( - `SELECT id, bible_text_id, local_file_path, blob_key, duration_ms, - file_size_bytes, take_number, is_latest, sync_status, upload_error, - created_at, updated_at + `SELECT id, bible_text_id, user_id, chapter_assignment_id, local_file_path, + blob_key, duration_ms, file_size_bytes, take_number, is_latest, + sync_status, upload_error, created_at, updated_at FROM recordings WHERE bible_text_id = ? AND is_latest = 1 LIMIT 1`, @@ -422,6 +440,8 @@ export async function getLatestRecordingForVerse( return { id: row.id, bibleTextId: row.bible_text_id, + userId: row.user_id ?? undefined, + chapterAssignmentId: row.chapter_assignment_id ?? undefined, localFilePath: row.local_file_path, blobKey: row.blob_key ?? undefined, durationMs: row.duration_ms ?? undefined, diff --git a/src/db/repository.recordings.test.ts b/src/db/repository.recordings.test.ts index a8797c9..3a2acbe 100644 --- a/src/db/repository.recordings.test.ts +++ b/src/db/repository.recordings.test.ts @@ -16,12 +16,19 @@ interface MockDb { let mockDb: MockDb; let mockTx: MockTx; +const mockDeleteRecordingFile = jest.fn(); + jest.mock('./db', () => { return { getDatabase: () => mockDb, }; }); +jest.mock('../services/recordingStorage', () => ({ + deleteRecordingFile: (pathOrKey: string) => + mockDeleteRecordingFile(pathOrKey), +})); + function makeDb(existingMaxTake: number): MockDb { mockTx = { execute: jest.fn().mockImplementation((sql: string) => { @@ -43,7 +50,9 @@ describe('insertRecording', () => { const baseInput: InsertRecordingInput = { id: 'rec-1', bibleTextId: 42, - localFilePath: '/tmp/rec-1.m4a', + userId: 'user-9', + chapterAssignmentId: 7, + localFilePath: 'recordings/uuser-9/p3/GEN/c001/v001/rec-1.m4a', durationMs: 12000, fileSizeBytes: 24000, createdAt: '2026-07-01T00:00:00.000Z', @@ -69,7 +78,9 @@ describe('insertRecording', () => { expect(insertParams).toEqual([ 'rec-1', 42, - '/tmp/rec-1.m4a', + 'user-9', + 7, + 'recordings/uuser-9/p3/GEN/c001/v001/rec-1.m4a', 12000, 24000, 1, @@ -86,7 +97,7 @@ describe('insertRecording', () => { expect(rec.takeNumber).toBe(4); const insertParams = mockTx.execute.mock.calls[2]?.[1] as unknown[]; - expect(insertParams?.[5]).toBe(4); + expect(insertParams?.[7]).toBe(4); }); it('accepts a custom sync status', async () => { @@ -96,13 +107,20 @@ describe('insertRecording', () => { expect(rec.syncStatus).toBe('error'); const insertParams = mockTx.execute.mock.calls[2]?.[1] as unknown[]; - expect(insertParams?.[6]).toBe('error'); + expect(insertParams?.[8]).toBe('error'); }); }); describe('deleteRecordingById', () => { - it('runs a DELETE query with the provided id', async () => { + beforeEach(() => { + mockDeleteRecordingFile.mockClear(); + }); + + it('runs a DELETE query and unlinks the stored file', async () => { mockDb = makeDb(0); + mockDb.execute.mockResolvedValueOnce({ + rows: [{ local_file_path: 'recordings/uu/p1/GEN/c001/v001/rec-9.m4a' }], + }); await deleteRecordingById('rec-9'); @@ -110,5 +128,17 @@ describe('deleteRecordingById', () => { expect.stringMatching(/DELETE FROM recordings WHERE id = \?/), ['rec-9'], ); + expect(mockDeleteRecordingFile).toHaveBeenCalledWith( + 'recordings/uu/p1/GEN/c001/v001/rec-9.m4a', + ); + }); + + it('skips unlink when no row is found', async () => { + mockDb = makeDb(0); + mockDb.execute.mockResolvedValueOnce({ rows: [] }); + + await deleteRecordingById('missing'); + + expect(mockDeleteRecordingFile).not.toHaveBeenCalled(); }); }); diff --git a/src/db/repository.ts b/src/db/repository.ts index 0693540..21ab2d2 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -2,6 +2,7 @@ import { getDatabase } from './db'; import { logger } from '../utils/logger'; import * as DBTypes from '../types/db/types'; import { Transaction } from '@op-engineering/op-sqlite'; +import { deleteRecordingFile } from '../services/recordingStorage'; const log = logger.create('DBRepository'); @@ -465,7 +466,10 @@ export async function userHasLocalProjects(userId: number): Promise { export interface InsertRecordingInput { id: string; bibleTextId: number; + /** Relative storage key (see `services/recordingStorage`), not an absolute path. */ localFilePath: string; + userId?: string | null; + chapterAssignmentId?: number | null; durationMs?: number | null; fileSizeBytes?: number | null; syncStatus?: DBTypes.RecordingSyncStatus; @@ -507,12 +511,15 @@ export async function insertRecording( await tx.execute( `INSERT INTO recordings - (id, bible_text_id, local_file_path, duration_ms, file_size_bytes, - take_number, is_latest, sync_status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, + (id, bible_text_id, user_id, chapter_assignment_id, local_file_path, + duration_ms, file_size_bytes, take_number, is_latest, sync_status, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, [ input.id, input.bibleTextId, + input.userId ?? null, + input.chapterAssignmentId ?? null, input.localFilePath, input.durationMs ?? null, input.fileSizeBytes ?? null, @@ -533,6 +540,8 @@ export async function insertRecording( return { id: input.id, bibleTextId: input.bibleTextId, + userId: input.userId ?? null, + chapterAssignmentId: input.chapterAssignmentId ?? null, localFilePath: input.localFilePath, durationMs: input.durationMs ?? null, fileSizeBytes: input.fileSizeBytes ?? null, @@ -544,10 +553,18 @@ export async function insertRecording( }; } -/** Deletes a recording row by id; caller unlinks the audio file separately. */ +/** Deletes a recording row and unlinks its durable audio file (best-effort). */ export async function deleteRecordingById(id: string): Promise { const db = getDatabase(); + const existing = await db.execute( + `SELECT local_file_path FROM recordings WHERE id = ?`, + [id], + ); + const path = (existing.rows as unknown as { local_file_path: string }[])?.[0] + ?.local_file_path; + await db.execute(`DELETE FROM recordings WHERE id = ?`, [id]); + if (path) deleteRecordingFile(path); log.info('Recording deleted', { id }); } diff --git a/src/db/schema.ts b/src/db/schema.ts index ffa91a4..f7b7388 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -80,6 +80,8 @@ export const createTableQueries: string[] = [ `CREATE TABLE IF NOT EXISTS recordings ( id TEXT PRIMARY KEY, bible_text_id INTEGER NOT NULL REFERENCES bible_texts(id), + user_id TEXT, + chapter_assignment_id INTEGER, local_file_path TEXT NOT NULL, blob_key TEXT, duration_ms INTEGER, @@ -110,3 +112,9 @@ export const chapterAssignmentMigrations: string[] = [ `ALTER TABLE chapter_assignments ADD COLUMN total_verses INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE chapter_assignments ADD COLUMN completed_verses INTEGER NOT NULL DEFAULT 0`, ]; + +/** For DBs created before recordings carried user/project attribution. */ +export const recordingMigrations: string[] = [ + `ALTER TABLE recordings ADD COLUMN user_id TEXT`, + `ALTER TABLE recordings ADD COLUMN chapter_assignment_id INTEGER`, +]; diff --git a/src/types/db/types.ts b/src/types/db/types.ts index 6f6f1cd..509a4e2 100644 --- a/src/types/db/types.ts +++ b/src/types/db/types.ts @@ -244,6 +244,8 @@ export type RecordingSyncStatus = export interface Recording { id: string; bibleTextId: number; + userId?: string | null; + chapterAssignmentId?: number | null; localFilePath: string; blobKey?: string | null; durationMs?: number | null; @@ -259,6 +261,8 @@ export interface Recording { export interface RecordingRow { id: string; bible_text_id: number; + user_id: string | null; + chapter_assignment_id: number | null; local_file_path: string; blob_key: string | null; duration_ms: number | null; From bac7e4bb38e6c13b1546aacf4b6b182478be4cdc Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 13:56:09 -0300 Subject: [PATCH 16/55] [#49]: Move committed takes into durable store in useRecorder Accept optional attribution context (user/project/book/chapter/verse) via a ref, move each committed take out of the cache into its durable key before inserting the row so a record never points at an evictable file, and resolve relative keys to absolute uris for playback. Refs #49 Co-authored-by: Cursor --- src/hooks/useRecorder.test.ts | 15 ++++++- src/hooks/useRecorder.ts | 85 +++++++++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/hooks/useRecorder.test.ts b/src/hooks/useRecorder.test.ts index c744f82..74cc520 100644 --- a/src/hooks/useRecorder.test.ts +++ b/src/hooks/useRecorder.test.ts @@ -62,6 +62,18 @@ jest.mock('../db/queries', () => ({ mockGetLatestRecordingForVerse(id), })); +const MOVED_KEY = 'recordings/u/p0/UNK/c000/v000/test-uuid-1.m4a'; + +jest.mock('../services/recordingStorage', () => ({ + buildRecordingKey: jest.fn(() => MOVED_KEY), + extensionFromUri: jest.fn(() => 'm4a'), + moveIntoStore: jest.fn(async ({ key }: { key: string }) => ({ + key, + sizeBytes: 1234, + })), + resolveRecordingUri: jest.fn((pathOrKey: string) => pathOrKey), +})); + jest.mock('../services/storage', () => ({ getPausedTake: (id: number) => mockGetPausedTake(id), setPausedTake: (marker: unknown) => mockSetPausedTake(marker), @@ -193,8 +205,9 @@ describe('useRecorder', () => { expect(mockInsertRecording).toHaveBeenCalledWith( expect.objectContaining({ bibleTextId: 42, - localFilePath: 'file:///tmp/take-1.m4a', + localFilePath: MOVED_KEY, durationMs: 5500, + fileSizeBytes: 1234, }), ); expect(mockClearPausedTake).toHaveBeenCalledWith(42); diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts index 807bf2e..a0c0f85 100644 --- a/src/hooks/useRecorder.ts +++ b/src/hooks/useRecorder.ts @@ -16,6 +16,12 @@ import { getPausedTake, setPausedTake, } from '../services/storage'; +import { + buildRecordingKey, + extensionFromUri, + moveIntoStore, + resolveRecordingUri, +} from '../services/recordingStorage'; import type { Recording } from '../types/db/types'; import { logger } from '../utils/logger'; import { useDraftPlayback } from './useDraftPlayback'; @@ -43,9 +49,19 @@ export interface PermissionRequestResult { * Re-record and Delete transitions from Review. * * `bibleTextId === null` keeps the recorder inert until a verse is resolved. + * + * The remaining fields are attribution context used to build the durable file + * path and persist who/where a take belongs to; they are optional so the hook + * stays usable in isolation (e.g. tests), falling back to placeholder segments. */ export interface UseRecorderArgs { bibleTextId: number | null; + userId?: string; + projectId?: number | null; + chapterAssignmentId?: number | null; + bookCode?: string | null; + chapterNumber?: number | null; + verseNumber?: number | null; } export interface UseRecorderApi { @@ -83,7 +99,15 @@ function mapPermissionState(response: PermissionLike): PermissionState { return 'denied'; } -export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { +export function useRecorder({ + bibleTextId, + userId, + projectId, + chapterAssignmentId, + bookCode, + chapterNumber, + verseNumber, +}: UseRecorderArgs): UseRecorderApi { const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); const [status, setStatus] = useState('idle'); @@ -97,11 +121,16 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { // Playback is a separate concern with a one-way dependency: it takes the // reviewed take's URI and knows nothing about the recorder. The recorder // orchestrates it explicitly (stopping playback before re-record/delete). + // Stored paths are relative keys, so resolve to an absolute uri here. const { isPlaying, toggle: togglePlaybackInternal, stop: stopPlayback, - } = useDraftPlayback(currentRecording?.localFilePath ?? null); + } = useDraftPlayback( + currentRecording + ? resolveRecordingUri(currentRecording.localFilePath) + : null, + ); const tickRef = useRef | null>(null); const startedAtRef = useRef(null); @@ -114,6 +143,25 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { bibleTextIdRef.current = bibleTextId; + // Attribution context is read via a ref so the commit callback always sees the + // latest values without being re-created (and re-subscribing) on every render. + const contextRef = useRef({ + userId, + projectId, + chapterAssignmentId, + bookCode, + chapterNumber, + verseNumber, + }); + contextRef.current = { + userId, + projectId, + chapterAssignmentId, + bookCode, + chapterNumber, + verseNumber, + }; + const clearTick = useCallback(() => { if (tickRef.current) { clearInterval(tickRef.current); @@ -310,11 +358,40 @@ export function useRecorder({ bibleTextId }: UseRecorderArgs): UseRecorderApi { return; } + const recordingId = randomUUID(); + const context = contextRef.current; + const key = buildRecordingKey({ + userId: context.userId ?? '', + projectId: context.projectId ?? 0, + bookCode: context.bookCode ?? '', + chapterNumber: context.chapterNumber ?? 0, + verseNumber: context.verseNumber ?? 0, + recordingId, + extension: extensionFromUri(fileUri), + }); + + // Move the take out of the evictable cache before recording it in the DB so + // a row never points at a file the OS could reclaim. + let moved; + try { + moved = await moveIntoStore({ sourceUri: fileUri, key }); + } catch (error) { + log.error('Failed to move recording into durable store', { + bibleTextId: currentBibleTextId, + error, + }); + setStatus(currentRecording ? 'review' : 'idle'); + return; + } + const inserted = await insertRecording({ - id: randomUUID(), + id: recordingId, bibleTextId: currentBibleTextId, - localFilePath: fileUri, + userId: context.userId ?? null, + chapterAssignmentId: context.chapterAssignmentId ?? null, + localFilePath: moved.key, durationMs: duration, + fileSizeBytes: moved.sizeBytes, }); clearPausedTake(currentBibleTextId); From cbc180f0d906a067f1310b8e71f8b6a7129ac1e2 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 13:56:18 -0300 Subject: [PATCH 17/55] [#49]: Wire attribution context into the Record tab Resolve the active user and owning project id in DraftingPage and pass them, along with the chapter assignment and book code, through RecordTab into useRecorder so committed takes are stored under the correct key. Refs #49 Co-authored-by: Cursor --- src/app/tabs/drafting/DraftingPage.tsx | 14 +++++++++++++ src/app/tabs/drafting/RecordTab.test.tsx | 25 +++++++++++++++++++++++- src/app/tabs/drafting/RecordTab.tsx | 14 +++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/app/tabs/drafting/DraftingPage.tsx b/src/app/tabs/drafting/DraftingPage.tsx index fd73db3..5ba57f2 100644 --- a/src/app/tabs/drafting/DraftingPage.tsx +++ b/src/app/tabs/drafting/DraftingPage.tsx @@ -8,7 +8,9 @@ import { getBibleTextId, getBibleTexts, getChapterAssignmentById, + getProjectIdForProjectUnit, } from '../../../db/queries'; +import { getActiveUserId } from '../../../services/storage'; import { StackScreenHeader } from '../../../components/layout/StackScreenHeader'; import { logger } from '../../../utils/logger'; import { BibleTab } from './BibleTab'; @@ -35,8 +37,10 @@ export default function DraftingPage() { const [verses, setVerses] = useState([]); const [selectedVerse, setSelectedVerse] = useState(1); const [bibleTextId, setBibleTextId] = useState(null); + const [projectId, setProjectId] = useState(null); const [activeTab, setActiveTab] = useState('record'); const [loading, setLoading] = useState(true); + const userId = getActiveUserId(); useEffect(() => { let cancelled = false; @@ -49,6 +53,12 @@ export default function DraftingPage() { if (cancelled) return; setChapterData(assignment); + const resolvedProjectId = await getProjectIdForProjectUnit( + assignment.projectUnitId, + ); + if (cancelled) return; + setProjectId(resolvedProjectId); + const texts = await getBibleTexts( assignment.bibleId, assignment.bookId, @@ -133,6 +143,10 @@ export default function DraftingPage() { selectedVerseNumber={selectedVerse} bibleTextIdForSelectedVerse={bibleTextId} onSelectVerse={setSelectedVerse} + userId={userId} + projectId={projectId} + chapterAssignmentId={chapterData.id} + bookCode={chapterData.bookCode} /> ) : ( diff --git a/src/app/tabs/drafting/RecordTab.test.tsx b/src/app/tabs/drafting/RecordTab.test.tsx index 7eb397e..de9fadb 100644 --- a/src/app/tabs/drafting/RecordTab.test.tsx +++ b/src/app/tabs/drafting/RecordTab.test.tsx @@ -20,7 +20,7 @@ jest.mock('@react-navigation/native', () => ({ })); jest.mock('../../../hooks/useRecorder', () => ({ - useRecorder: () => mockUseRecorder(), + useRecorder: (args: unknown) => mockUseRecorder(args), })); const VERSES: VerseData[] = [ @@ -328,6 +328,29 @@ describe('RecordTab', () => { alertSpy.mockRestore(); }); + it('forwards attribution context to useRecorder', () => { + mockUseRecorder.mockReturnValue(baseRecorderState()); + renderTab({ + selectedVerseNumber: 2, + userId: 'user-7', + projectId: 55, + chapterAssignmentId: 88, + bookCode: 'MRK', + }); + + expect(mockUseRecorder).toHaveBeenCalledWith( + expect.objectContaining({ + bibleTextId: 42, + userId: 'user-7', + projectId: 55, + chapterAssignmentId: 88, + bookCode: 'MRK', + chapterNumber: 14, + verseNumber: 2, + }), + ); + }); + it('source text accordion is collapsed by default and toggles open', () => { mockUseRecorder.mockReturnValue(baseRecorderState()); renderTab(); diff --git a/src/app/tabs/drafting/RecordTab.tsx b/src/app/tabs/drafting/RecordTab.tsx index 4f07879..cddc7cb 100644 --- a/src/app/tabs/drafting/RecordTab.tsx +++ b/src/app/tabs/drafting/RecordTab.tsx @@ -41,6 +41,10 @@ interface RecordTabProps { selectedVerseNumber: number; bibleTextIdForSelectedVerse: number | null; onSelectVerse: (verseNumber: number) => void; + userId?: string; + projectId?: number | null; + chapterAssignmentId?: number | null; + bookCode?: string | null; } function formatDuration(ms: number): string { @@ -65,10 +69,20 @@ export function RecordTab({ selectedVerseNumber, bibleTextIdForSelectedVerse, onSelectVerse, + userId, + projectId, + chapterAssignmentId, + bookCode, }: RecordTabProps) { const navigation = useNavigation(); const recorder = useRecorder({ bibleTextId: bibleTextIdForSelectedVerse, + userId, + projectId, + chapterAssignmentId, + bookCode, + chapterNumber, + verseNumber: selectedVerseNumber, }); const [sourceExpanded, setSourceExpanded] = useState(false); From bc87ec2ac7dcdb517a4bdab690111d3258a729ec Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 14:43:29 -0300 Subject: [PATCH 18/55] [#49]: Capture takes into durable dir and unlink discarded partials Configure useAudioRecorder to write into the document directory instead of the evictable cache so paused or backgrounded partial takes survive a process kill until they are moved or deleted. On discardPaused, read the persisted paused-take marker (recorder.uri is unreliable after a kill) and unlink the durable partial file before clearing state. Refs #49 Co-authored-by: Cursor --- src/hooks/useRecorder.test.ts | 51 ++++++++++++++++++++++++++++++++++- src/hooks/useRecorder.ts | 13 ++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/hooks/useRecorder.test.ts b/src/hooks/useRecorder.test.ts index 74cc520..cf781b0 100644 --- a/src/hooks/useRecorder.test.ts +++ b/src/hooks/useRecorder.test.ts @@ -11,6 +11,7 @@ const mockRecorder = { prepareToRecordAsync: jest.fn().mockResolvedValue(undefined), }; +const mockUseAudioRecorder = jest.fn(); const mockInsertRecording = jest.fn(); const mockDeleteRecordingById = jest.fn().mockResolvedValue(undefined); const mockGetLatestRecordingForVerse = jest.fn().mockResolvedValue(null); @@ -36,7 +37,10 @@ jest.mock('expo-crypto', () => ({ jest.mock('expo-audio', () => ({ RecordingPresets: { HIGH_QUALITY: {} }, - useAudioRecorder: () => mockRecorder, + useAudioRecorder: (options: unknown) => { + mockUseAudioRecorder(options); + return mockRecorder; + }, useAudioPlayer: () => mockPlayer, useAudioPlayerStatus: () => mockPlayerStatus, requestRecordingPermissionsAsync: jest.fn().mockResolvedValue({ @@ -64,6 +68,8 @@ jest.mock('../db/queries', () => ({ const MOVED_KEY = 'recordings/u/p0/UNK/c000/v000/test-uuid-1.m4a'; +const mockDeleteRecordingFile = jest.fn(); + jest.mock('../services/recordingStorage', () => ({ buildRecordingKey: jest.fn(() => MOVED_KEY), extensionFromUri: jest.fn(() => 'm4a'), @@ -72,6 +78,8 @@ jest.mock('../services/recordingStorage', () => ({ sizeBytes: 1234, })), resolveRecordingUri: jest.fn((pathOrKey: string) => pathOrKey), + deleteRecordingFile: (pathOrKey: string) => + mockDeleteRecordingFile(pathOrKey), })); jest.mock('../services/storage', () => ({ @@ -106,6 +114,15 @@ describe('useRecorder', () => { createdAt: '2026-07-01T00:00:00.000Z', updatedAt: '2026-07-01T00:00:00.000Z', })); + // Default AppState subscription so the background-listener cleanup has a + // valid `remove()` on unmount; individual tests may override the impl. + jest.spyOn(AppState, 'addEventListener').mockReturnValue({ + remove: jest.fn(), + } as unknown as ReturnType); + }); + + afterEach(() => { + jest.restoreAllMocks(); }); it('starts in idle when the verse has no existing draft', async () => { @@ -449,4 +466,36 @@ describe('useRecorder', () => { addSpy.mockRestore(); }); + + it('captures recordings into the durable document directory', async () => { + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + + expect(mockUseAudioRecorder).toHaveBeenCalledWith( + expect.objectContaining({ directory: 'document' }), + ); + }); + + it('unlinks the durable partial file when a paused take is discarded', async () => { + mockGetPausedTake.mockReturnValue({ + bibleTextId: 42, + fileUri: 'file:///docs/partial-take.m4a', + elapsedMs: 4500, + startedAt: '2026-07-01T00:00:00.000Z', + }); + + const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + await waitReady(result); + expect(result.current.status).toBe('paused'); + + await act(async () => { + await result.current.discardPaused(); + }); + + expect(mockDeleteRecordingFile).toHaveBeenCalledWith( + 'file:///docs/partial-take.m4a', + ); + expect(mockClearPausedTake).toHaveBeenCalledWith(42); + expect(result.current.status).toBe('idle'); + }); }); diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts index a0c0f85..ffdc67f 100644 --- a/src/hooks/useRecorder.ts +++ b/src/hooks/useRecorder.ts @@ -18,6 +18,7 @@ import { } from '../services/storage'; import { buildRecordingKey, + deleteRecordingFile, extensionFromUri, moveIntoStore, resolveRecordingUri, @@ -108,7 +109,13 @@ export function useRecorder({ chapterNumber, verseNumber, }: UseRecorderArgs): UseRecorderApi { - const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); + // Capture straight into the durable document directory (not the evictable + // cache) so paused/backgrounded partial takes survive until we move or delete + // them. Committed takes are relocated into the organized tree on Stop. + const recorder = useAudioRecorder({ + ...RecordingPresets.HIGH_QUALITY, + directory: 'document', + }); const [status, setStatus] = useState('idle'); const [permission, setPermission] = useState('unknown'); @@ -438,11 +445,15 @@ export function useRecorder({ const discardPaused = useCallback(async () => { const currentBibleTextId = bibleTextIdRef.current; if (currentBibleTextId === null) return; + // Read the marker before clearing so we can unlink the durable partial file + // (recorder.uri is unreliable after a process kill; the marker is not). + const marker = getPausedTake(currentBibleTextId); try { await recorder.stop(); } catch (error) { log.warn('Recorder stop while discarding paused take failed', { error }); } + if (marker?.fileUri) deleteRecordingFile(marker.fileUri); clearPausedTake(currentBibleTextId); clearTick(); baseElapsedRef.current = 0; From 2516d1acc4a206f659c54a657dd0c340b1525688 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 18:53:09 -0300 Subject: [PATCH 19/55] [#49]: Extract a generic recorder hook behind a verse adapter Split useRecorder into a use-case-agnostic state machine that owns only recording mechanics (transitions, elapsed ticking, permissions, background auto-pause, playback) and injects persistence, storage layout, and domain identity through a RecorderAdapter. Move all verse-specific SQLite, durable-storage, and paused-marker wiring into a new useVerseRecorder adapter and point RecordTab at it, so the recorder can be reused for other capture use cases without dragging DB concerns along. Refs #49 Co-authored-by: Cursor --- src/app/tabs/drafting/RecordTab.test.tsx | 38 +-- src/app/tabs/drafting/RecordTab.tsx | 4 +- src/hooks/useRecorder.test.ts | 300 ++++++++++------------- src/hooks/useRecorder.ts | 235 +++++++++--------- src/hooks/useVerseRecorder.test.ts | 287 ++++++++++++++++++++++ src/hooks/useVerseRecorder.ts | 135 ++++++++++ 6 files changed, 684 insertions(+), 315 deletions(-) create mode 100644 src/hooks/useVerseRecorder.test.ts create mode 100644 src/hooks/useVerseRecorder.ts diff --git a/src/app/tabs/drafting/RecordTab.test.tsx b/src/app/tabs/drafting/RecordTab.test.tsx index de9fadb..32955b1 100644 --- a/src/app/tabs/drafting/RecordTab.test.tsx +++ b/src/app/tabs/drafting/RecordTab.test.tsx @@ -9,7 +9,7 @@ import { import { RecordTab } from './RecordTab'; import type { VerseData } from '../../../types/db/types'; -const mockUseRecorder = jest.fn(); +const mockUseVerseRecorder = jest.fn(); jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ @@ -19,8 +19,8 @@ jest.mock('@react-navigation/native', () => ({ }), })); -jest.mock('../../../hooks/useRecorder', () => ({ - useRecorder: (args: unknown) => mockUseRecorder(args), +jest.mock('../../../hooks/useVerseRecorder', () => ({ + useVerseRecorder: (args: unknown) => mockUseVerseRecorder(args), })); const VERSES: VerseData[] = [ @@ -87,7 +87,7 @@ describe('RecordTab', () => { }); it('renders the idle record button with a label and disabled play hint', () => { - mockUseRecorder.mockReturnValue(baseRecorderState()); + mockUseVerseRecorder.mockReturnValue(baseRecorderState()); renderTab(); expect(screen.getByTestId('record-verse-reference')).toHaveTextContent( @@ -101,7 +101,7 @@ describe('RecordTab', () => { }); it('shows pause and stop controls with a duration and tip when recording', () => { - mockUseRecorder.mockReturnValue({ + mockUseVerseRecorder.mockReturnValue({ ...baseRecorderState(), status: 'recording', elapsedMs: 65_420, @@ -117,7 +117,7 @@ describe('RecordTab', () => { }); it('shows the paused tip instead of the recording tip while paused', () => { - mockUseRecorder.mockReturnValue({ + mockUseVerseRecorder.mockReturnValue({ ...baseRecorderState(), status: 'paused', elapsedMs: 3_000, @@ -130,7 +130,7 @@ describe('RecordTab', () => { }); it('shows the resume button while paused', () => { - mockUseRecorder.mockReturnValue({ + mockUseVerseRecorder.mockReturnValue({ ...baseRecorderState(), status: 'paused', elapsedMs: 3_000, @@ -147,7 +147,7 @@ describe('RecordTab', () => { status: 'review' as const, currentRecording: reviewRecording(), }; - mockUseRecorder.mockReturnValue(state); + mockUseVerseRecorder.mockReturnValue(state); const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); renderTab(); @@ -176,7 +176,7 @@ describe('RecordTab', () => { it('plays the draft when the review play button is pressed', () => { const togglePlayback = jest.fn().mockResolvedValue(undefined); - mockUseRecorder.mockReturnValue({ + mockUseVerseRecorder.mockReturnValue({ ...baseRecorderState(), status: 'review', currentRecording: reviewRecording(), @@ -194,7 +194,7 @@ describe('RecordTab', () => { }); it('shows a pause affordance while the draft is playing', () => { - mockUseRecorder.mockReturnValue({ + mockUseVerseRecorder.mockReturnValue({ ...baseRecorderState(), status: 'review', currentRecording: reviewRecording(), @@ -211,7 +211,7 @@ describe('RecordTab', () => { }); it('disables verse chevrons while recording even when navigation is possible', () => { - mockUseRecorder.mockReturnValue({ + mockUseVerseRecorder.mockReturnValue({ ...baseRecorderState(), status: 'recording', elapsedMs: 4_000, @@ -227,7 +227,7 @@ describe('RecordTab', () => { }); it('disables prev on the first verse and next on the last verse', () => { - mockUseRecorder.mockReturnValue(baseRecorderState()); + mockUseVerseRecorder.mockReturnValue(baseRecorderState()); const { rerender } = renderTab({ selectedVerseNumber: 1 }); expect( @@ -258,7 +258,7 @@ describe('RecordTab', () => { .fn() .mockResolvedValue({ granted: true, canAskAgain: true }); const start = jest.fn().mockResolvedValue(undefined); - mockUseRecorder.mockReturnValue({ + mockUseVerseRecorder.mockReturnValue({ ...baseRecorderState(), permission: 'denied', requestPermission, @@ -276,7 +276,7 @@ describe('RecordTab', () => { const requestPermission = jest.fn(); const start = jest.fn(); const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); - mockUseRecorder.mockReturnValue({ + mockUseVerseRecorder.mockReturnValue({ ...baseRecorderState(), permission: 'blocked', requestPermission, @@ -305,7 +305,7 @@ describe('RecordTab', () => { .mockResolvedValue({ granted: false, canAskAgain: false }); const start = jest.fn(); const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); - mockUseRecorder.mockReturnValue({ + mockUseVerseRecorder.mockReturnValue({ ...baseRecorderState(), permission: 'denied', requestPermission, @@ -328,8 +328,8 @@ describe('RecordTab', () => { alertSpy.mockRestore(); }); - it('forwards attribution context to useRecorder', () => { - mockUseRecorder.mockReturnValue(baseRecorderState()); + it('forwards attribution context to useVerseRecorder', () => { + mockUseVerseRecorder.mockReturnValue(baseRecorderState()); renderTab({ selectedVerseNumber: 2, userId: 'user-7', @@ -338,7 +338,7 @@ describe('RecordTab', () => { bookCode: 'MRK', }); - expect(mockUseRecorder).toHaveBeenCalledWith( + expect(mockUseVerseRecorder).toHaveBeenCalledWith( expect.objectContaining({ bibleTextId: 42, userId: 'user-7', @@ -352,7 +352,7 @@ describe('RecordTab', () => { }); it('source text accordion is collapsed by default and toggles open', () => { - mockUseRecorder.mockReturnValue(baseRecorderState()); + mockUseVerseRecorder.mockReturnValue(baseRecorderState()); renderTab(); expect(screen.queryByTestId('record-source-body')).toBeNull(); diff --git a/src/app/tabs/drafting/RecordTab.tsx b/src/app/tabs/drafting/RecordTab.tsx index cddc7cb..01ad418 100644 --- a/src/app/tabs/drafting/RecordTab.tsx +++ b/src/app/tabs/drafting/RecordTab.tsx @@ -26,7 +26,7 @@ import { useNavigation } from '@react-navigation/native'; import { theme } from '../../../theme'; import { iconSizes, listIconStrokeWidth } from '../../../theme/iconSpecs'; import type { VerseData } from '../../../types/db/types'; -import { useRecorder } from '../../../hooks/useRecorder'; +import { useVerseRecorder } from '../../../hooks/useVerseRecorder'; if (Platform.OS === 'android') { UIManager.setLayoutAnimationEnabledExperimental?.(true); @@ -75,7 +75,7 @@ export function RecordTab({ bookCode, }: RecordTabProps) { const navigation = useNavigation(); - const recorder = useRecorder({ + const recorder = useVerseRecorder({ bibleTextId: bibleTextIdForSelectedVerse, userId, projectId, diff --git a/src/hooks/useRecorder.test.ts b/src/hooks/useRecorder.test.ts index cf781b0..27582ff 100644 --- a/src/hooks/useRecorder.test.ts +++ b/src/hooks/useRecorder.test.ts @@ -1,6 +1,11 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; import { AppState } from 'react-native'; -import { useRecorder } from './useRecorder'; +import { useRecorder, type RecorderAdapter } from './useRecorder'; + +interface FakeTake { + id: string; + uri: string; +} const mockRecorder = { currentTime: 0, @@ -12,12 +17,6 @@ const mockRecorder = { }; const mockUseAudioRecorder = jest.fn(); -const mockInsertRecording = jest.fn(); -const mockDeleteRecordingById = jest.fn().mockResolvedValue(undefined); -const mockGetLatestRecordingForVerse = jest.fn().mockResolvedValue(null); -const mockGetPausedTake = jest.fn().mockReturnValue(null); -const mockSetPausedTake = jest.fn(); -const mockClearPausedTake = jest.fn(); const mockPlayer = { play: jest.fn(), @@ -31,10 +30,6 @@ let mockPlayerStatus: { playing?: boolean; didJustFinish?: boolean } = { didJustFinish: false, }; -jest.mock('expo-crypto', () => ({ - randomUUID: jest.fn(() => 'test-uuid-1'), -})); - jest.mock('expo-audio', () => ({ RecordingPresets: { HIGH_QUALITY: {} }, useAudioRecorder: (options: unknown) => { @@ -56,37 +51,27 @@ jest.mock('expo-audio', () => ({ setAudioModeAsync: jest.fn().mockResolvedValue(undefined), })); -jest.mock('../db/repository', () => ({ - insertRecording: (input: unknown) => mockInsertRecording(input), - deleteRecordingById: (id: string) => mockDeleteRecordingById(id), -})); - -jest.mock('../db/queries', () => ({ - getLatestRecordingForVerse: (id: number) => - mockGetLatestRecordingForVerse(id), -})); - -const MOVED_KEY = 'recordings/u/p0/UNK/c000/v000/test-uuid-1.m4a'; - -const mockDeleteRecordingFile = jest.fn(); - -jest.mock('../services/recordingStorage', () => ({ - buildRecordingKey: jest.fn(() => MOVED_KEY), - extensionFromUri: jest.fn(() => 'm4a'), - moveIntoStore: jest.fn(async ({ key }: { key: string }) => ({ - key, - sizeBytes: 1234, - })), - resolveRecordingUri: jest.fn((pathOrKey: string) => pathOrKey), - deleteRecordingFile: (pathOrKey: string) => - mockDeleteRecordingFile(pathOrKey), -})); +const COMMITTED_TAKE: FakeTake = { + id: 'take-1', + uri: 'file:///committed/take-1.m4a', +}; -jest.mock('../services/storage', () => ({ - getPausedTake: (id: number) => mockGetPausedTake(id), - setPausedTake: (marker: unknown) => mockSetPausedTake(marker), - clearPausedTake: (id: number) => mockClearPausedTake(id), -})); +function makeAdapter( + overrides: Partial> = {}, +): RecorderAdapter { + return { + sessionKey: 42, + loadInitial: jest.fn().mockResolvedValue(null), + onCommit: jest.fn().mockResolvedValue(COMMITTED_TAKE), + deleteCommitted: jest.fn().mockResolvedValue(undefined), + resolvePlaybackUri: jest.fn((take: FakeTake) => take.uri), + loadPaused: jest.fn().mockReturnValue(null), + persistPaused: jest.fn(), + clearPaused: jest.fn(), + deletePausedFile: jest.fn(), + ...overrides, + }; +} async function waitReady(result: { current: { isReady: boolean } }) { await waitFor(() => expect(result.current.isReady).toBe(true)); @@ -100,20 +85,6 @@ describe('useRecorder', () => { mockPlayer.currentTime = 0; mockPlayer.duration = 0; mockPlayerStatus = { playing: false, didJustFinish: false }; - mockGetLatestRecordingForVerse.mockResolvedValue(null); - mockGetPausedTake.mockReturnValue(null); - mockInsertRecording.mockImplementation(async input => ({ - id: input.id, - bibleTextId: input.bibleTextId, - localFilePath: input.localFilePath, - durationMs: input.durationMs ?? null, - fileSizeBytes: input.fileSizeBytes ?? null, - takeNumber: 1, - isLatest: true, - syncStatus: 'pending' as const, - createdAt: '2026-07-01T00:00:00.000Z', - updatedAt: '2026-07-01T00:00:00.000Z', - })); // Default AppState subscription so the background-listener cleanup has a // valid `remove()` on unmount; individual tests may override the impl. jest.spyOn(AppState, 'addEventListener').mockReturnValue({ @@ -125,45 +96,53 @@ describe('useRecorder', () => { jest.restoreAllMocks(); }); - it('starts in idle when the verse has no existing draft', async () => { - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + it('stays inert while the session key is null', async () => { + const adapter = makeAdapter({ sessionKey: null }); + const { result } = renderHook(() => useRecorder(adapter)); + // A null session never becomes ready and never queries the adapter. + await waitFor(() => expect(adapter.loadInitial).not.toHaveBeenCalled()); + expect(result.current.status).toBe('idle'); + expect(result.current.currentRecording).toBeNull(); + expect(result.current.isReady).toBe(false); + }); + + it('starts in idle when the session has no existing take', async () => { + const adapter = makeAdapter(); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); expect(result.current.status).toBe('idle'); expect(result.current.currentRecording).toBeNull(); + expect(adapter.loadInitial).toHaveBeenCalledWith(42); }); - it('starts in review when a draft already exists', async () => { - mockGetLatestRecordingForVerse.mockResolvedValueOnce({ - id: 'existing', - bibleTextId: 42, - localFilePath: '/tmp/existing.m4a', - takeNumber: 1, - isLatest: true, - syncStatus: 'pending', - createdAt: 'x', - updatedAt: 'x', + it('starts in review when a take already exists', async () => { + const existing: FakeTake = { id: 'existing', uri: '/tmp/existing.m4a' }; + const adapter = makeAdapter({ + loadInitial: jest.fn().mockResolvedValue(existing), }); - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); expect(result.current.status).toBe('review'); expect(result.current.currentRecording?.id).toBe('existing'); }); it('surfaces a persisted paused-take marker on mount', async () => { - mockGetPausedTake.mockReturnValueOnce({ - bibleTextId: 42, - fileUri: '/tmp/paused.m4a', - elapsedMs: 4500, - startedAt: '2026-07-01T00:00:00.000Z', + const adapter = makeAdapter({ + loadPaused: jest.fn().mockReturnValue({ + fileUri: '/tmp/paused.m4a', + elapsedMs: 4500, + startedAt: '2026-07-01T00:00:00.000Z', + }), }); - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); expect(result.current.status).toBe('paused'); expect(result.current.elapsedMs).toBe(4500); }); it('transitions idle -> recording on start', async () => { - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const adapter = makeAdapter(); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); await act(async () => { @@ -179,7 +158,8 @@ describe('useRecorder', () => { jest.useFakeTimers(); try { - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const adapter = makeAdapter(); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); // Anchor after mount so `waitFor`'s internal poll drift doesn't skew t0. @@ -194,9 +174,8 @@ describe('useRecorder', () => { }); expect(mockRecorder.pause).toHaveBeenCalled(); - expect(mockSetPausedTake).toHaveBeenCalledWith( + expect(adapter.persistPaused).toHaveBeenCalledWith( expect.objectContaining({ - bibleTextId: 42, fileUri: 'file:///tmp/take-1.m4a', elapsedMs: 2500, }), @@ -219,34 +198,25 @@ describe('useRecorder', () => { }); expect(mockRecorder.stop).toHaveBeenCalled(); - expect(mockInsertRecording).toHaveBeenCalledWith( - expect.objectContaining({ - bibleTextId: 42, - localFilePath: MOVED_KEY, - durationMs: 5500, - fileSizeBytes: 1234, - }), - ); - expect(mockClearPausedTake).toHaveBeenCalledWith(42); + expect(adapter.onCommit).toHaveBeenCalledWith({ + fileUri: 'file:///tmp/take-1.m4a', + durationMs: 5500, + }); + expect(adapter.clearPaused).toHaveBeenCalled(); expect(result.current.status).toBe('review'); + expect(result.current.currentRecording?.id).toBe('take-1'); } finally { jest.useRealTimers(); } }); - it('re-record starts a new recording and stop overwrites the previous draft', async () => { - mockGetLatestRecordingForVerse.mockResolvedValueOnce({ - id: 'existing', - bibleTextId: 42, - localFilePath: '/tmp/existing.m4a', - takeNumber: 1, - isLatest: true, - syncStatus: 'pending', - createdAt: 'x', - updatedAt: 'x', + it('re-record starts a new recording and stop overwrites the previous take', async () => { + const existing: FakeTake = { id: 'existing', uri: '/tmp/existing.m4a' }; + const adapter = makeAdapter({ + loadInitial: jest.fn().mockResolvedValue(existing), }); - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); expect(result.current.status).toBe('review'); @@ -261,31 +231,45 @@ describe('useRecorder', () => { await result.current.stop(); }); - expect(mockInsertRecording).toHaveBeenCalledTimes(1); + expect(adapter.onCommit).toHaveBeenCalledTimes(1); expect(result.current.status).toBe('review'); - expect(result.current.currentRecording?.id).not.toBe('existing'); + expect(result.current.currentRecording?.id).toBe('take-1'); + }); + + it('reverts without committing when the adapter reports a commit failure', async () => { + const adapter = makeAdapter({ + onCommit: jest.fn().mockResolvedValue(null), + }); + const { result } = renderHook(() => useRecorder(adapter)); + await waitReady(result); + + await act(async () => { + await result.current.start(); + }); + await act(async () => { + await result.current.stop(); + }); + + expect(adapter.onCommit).toHaveBeenCalled(); + expect(adapter.clearPaused).not.toHaveBeenCalled(); + expect(result.current.status).toBe('idle'); + expect(result.current.currentRecording).toBeNull(); }); - it('delete removes the current draft and returns to idle', async () => { - mockGetLatestRecordingForVerse.mockResolvedValueOnce({ - id: 'existing', - bibleTextId: 42, - localFilePath: '/tmp/existing.m4a', - takeNumber: 1, - isLatest: true, - syncStatus: 'pending', - createdAt: 'x', - updatedAt: 'x', + it('delete removes the current take and returns to idle', async () => { + const existing: FakeTake = { id: 'existing', uri: '/tmp/existing.m4a' }; + const adapter = makeAdapter({ + loadInitial: jest.fn().mockResolvedValue(existing), }); - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); await act(async () => { await result.current.deleteCurrent(); }); - expect(mockDeleteRecordingById).toHaveBeenCalledWith('existing'); + expect(adapter.deleteCommitted).toHaveBeenCalledWith(existing); expect(result.current.status).toBe('idle'); expect(result.current.currentRecording).toBeNull(); }); @@ -300,7 +284,7 @@ describe('useRecorder', () => { status: 'denied', }); - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const { result } = renderHook(() => useRecorder(makeAdapter())); await waitReady(result); await waitFor(() => expect(result.current.permission).toBe('blocked')); }); @@ -321,7 +305,7 @@ describe('useRecorder', () => { status: 'denied', }); - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const { result } = renderHook(() => useRecorder(makeAdapter())); await waitReady(result); await waitFor(() => expect(result.current.permission).toBe('denied')); @@ -334,19 +318,13 @@ describe('useRecorder', () => { expect(result.current.permission).toBe('blocked'); }); - it('plays the current draft on togglePlayback from review', async () => { - mockGetLatestRecordingForVerse.mockResolvedValueOnce({ - id: 'existing', - bibleTextId: 42, - localFilePath: '/tmp/existing.m4a', - takeNumber: 1, - isLatest: true, - syncStatus: 'pending', - createdAt: 'x', - updatedAt: 'x', + it('plays the current take on togglePlayback from review', async () => { + const existing: FakeTake = { id: 'existing', uri: '/tmp/existing.m4a' }; + const adapter = makeAdapter({ + loadInitial: jest.fn().mockResolvedValue(existing), }); - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); expect(result.current.status).toBe('review'); expect(result.current.isPlaying).toBe(false); @@ -360,19 +338,13 @@ describe('useRecorder', () => { }); it('pauses playback when toggled while already playing', async () => { - mockGetLatestRecordingForVerse.mockResolvedValueOnce({ - id: 'existing', - bibleTextId: 42, - localFilePath: '/tmp/existing.m4a', - takeNumber: 1, - isLatest: true, - syncStatus: 'pending', - createdAt: 'x', - updatedAt: 'x', + const existing: FakeTake = { id: 'existing', uri: '/tmp/existing.m4a' }; + const adapter = makeAdapter({ + loadInitial: jest.fn().mockResolvedValue(existing), }); mockPlayerStatus = { playing: true, didJustFinish: false }; - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); expect(result.current.isPlaying).toBe(true); @@ -384,8 +356,8 @@ describe('useRecorder', () => { expect(mockPlayer.play).not.toHaveBeenCalled(); }); - it('does not play when there is no draft to review', async () => { - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + it('does not play when there is no take to review', async () => { + const { result } = renderHook(() => useRecorder(makeAdapter())); await waitReady(result); expect(result.current.status).toBe('idle'); @@ -397,38 +369,26 @@ describe('useRecorder', () => { }); it('rewinds to the start when a take finishes playing', async () => { - mockGetLatestRecordingForVerse.mockResolvedValueOnce({ - id: 'existing', - bibleTextId: 42, - localFilePath: '/tmp/existing.m4a', - takeNumber: 1, - isLatest: true, - syncStatus: 'pending', - createdAt: 'x', - updatedAt: 'x', + const existing: FakeTake = { id: 'existing', uri: '/tmp/existing.m4a' }; + const adapter = makeAdapter({ + loadInitial: jest.fn().mockResolvedValue(existing), }); mockPlayerStatus = { playing: false, didJustFinish: true }; - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); await waitFor(() => expect(mockPlayer.seekTo).toHaveBeenCalledWith(0)); }); - it('stops playback before re-recording an existing draft', async () => { - mockGetLatestRecordingForVerse.mockResolvedValueOnce({ - id: 'existing', - bibleTextId: 42, - localFilePath: '/tmp/existing.m4a', - takeNumber: 1, - isLatest: true, - syncStatus: 'pending', - createdAt: 'x', - updatedAt: 'x', + it('stops playback before re-recording an existing take', async () => { + const existing: FakeTake = { id: 'existing', uri: '/tmp/existing.m4a' }; + const adapter = makeAdapter({ + loadInitial: jest.fn().mockResolvedValue(existing), }); mockPlayerStatus = { playing: true, didJustFinish: false }; - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); await act(async () => { @@ -448,7 +408,8 @@ describe('useRecorder', () => { return { remove: jest.fn() }; }) as unknown as typeof AppState.addEventListener); - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const adapter = makeAdapter(); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); await act(async () => { @@ -461,14 +422,14 @@ describe('useRecorder', () => { }); expect(mockRecorder.pause).toHaveBeenCalled(); - expect(mockSetPausedTake).toHaveBeenCalled(); + expect(adapter.persistPaused).toHaveBeenCalled(); expect(result.current.status).toBe('paused'); addSpy.mockRestore(); }); it('captures recordings into the durable document directory', async () => { - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const { result } = renderHook(() => useRecorder(makeAdapter())); await waitReady(result); expect(mockUseAudioRecorder).toHaveBeenCalledWith( @@ -477,14 +438,15 @@ describe('useRecorder', () => { }); it('unlinks the durable partial file when a paused take is discarded', async () => { - mockGetPausedTake.mockReturnValue({ - bibleTextId: 42, - fileUri: 'file:///docs/partial-take.m4a', - elapsedMs: 4500, - startedAt: '2026-07-01T00:00:00.000Z', + const adapter = makeAdapter({ + loadPaused: jest.fn().mockReturnValue({ + fileUri: 'file:///docs/partial-take.m4a', + elapsedMs: 4500, + startedAt: '2026-07-01T00:00:00.000Z', + }), }); - const { result } = renderHook(() => useRecorder({ bibleTextId: 42 })); + const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); expect(result.current.status).toBe('paused'); @@ -492,10 +454,10 @@ describe('useRecorder', () => { await result.current.discardPaused(); }); - expect(mockDeleteRecordingFile).toHaveBeenCalledWith( + expect(adapter.deletePausedFile).toHaveBeenCalledWith( 'file:///docs/partial-take.m4a', ); - expect(mockClearPausedTake).toHaveBeenCalledWith(42); + expect(adapter.clearPaused).toHaveBeenCalled(); expect(result.current.status).toBe('idle'); }); }); diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts index ffdc67f..5bca7c8 100644 --- a/src/hooks/useRecorder.ts +++ b/src/hooks/useRecorder.ts @@ -8,22 +8,6 @@ import { useAudioRecorder, } from 'expo-audio'; import type { AudioRecorder } from 'expo-audio'; -import { randomUUID } from 'expo-crypto'; -import { deleteRecordingById, insertRecording } from '../db/repository'; -import { getLatestRecordingForVerse } from '../db/queries'; -import { - clearPausedTake, - getPausedTake, - setPausedTake, -} from '../services/storage'; -import { - buildRecordingKey, - deleteRecordingFile, - extensionFromUri, - moveIntoStore, - resolveRecordingUri, -} from '../services/recordingStorage'; -import type { Recording } from '../types/db/types'; import { logger } from '../utils/logger'; import { useDraftPlayback } from './useDraftPlayback'; @@ -45,31 +29,58 @@ export interface PermissionRequestResult { } /** - * Wraps `expo-audio`'s recorder and player primitives with the state machine - * described in issue #49: Idle -> Recording -> Paused -> Review, plus - * Re-record and Delete transitions from Review. - * - * `bibleTextId === null` keeps the recorder inert until a verse is resolved. + * A partial take captured before the recorder was paused (manually or by + * backgrounding). Persisted by the adapter so an in-flight take survives a + * process kill; the `startedAt`/`elapsedMs` pair rehydrates the timer on mount. + */ +export interface PausedTakeState { + fileUri: string; + elapsedMs: number; + startedAt: string; +} + +/** + * Use-case adapter injected into {@link useRecorder}. It isolates every concern + * the generic recorder does not own: which session a take belongs to, how a + * committed take is persisted/resolved/deleted, and where paused-take markers + * live. The recorder itself stays agnostic of the DB, storage layout, and any + * domain vocabulary. * - * The remaining fields are attribution context used to build the durable file - * path and persist who/where a take belongs to; they are optional so the hook - * stays usable in isolation (e.g. tests), falling back to placeholder segments. + * `sessionKey === null` keeps the recorder inert until the caller resolves a + * target (e.g. a selected verse). */ -export interface UseRecorderArgs { - bibleTextId: number | null; - userId?: string; - projectId?: number | null; - chapterAssignmentId?: number | null; - bookCode?: string | null; - chapterNumber?: number | null; - verseNumber?: number | null; +export interface RecorderAdapter { + sessionKey: number | string | null; + /** Load the most recent committed take for the session, if any. */ + loadInitial: (key: number | string) => Promise; + /** + * Persist a freshly stopped take. Return the committed take on success, or + * `null` to signal a recoverable failure (the recorder reverts to its prior + * state). Throwing is also treated as a recoverable failure. + */ + onCommit: (args: { + fileUri: string; + durationMs: number; + }) => Promise; + /** Delete a committed take (row + file). */ + deleteCommitted: (take: T) => Promise; + /** Resolve a committed take to an absolute, playable uri. */ + resolvePlaybackUri: (take: T) => string | null; + /** Read the paused-take marker for the session, if one exists. */ + loadPaused: (key: number | string) => PausedTakeState | null; + /** Write the paused-take marker for the current session. */ + persistPaused: (state: PausedTakeState) => void; + /** Clear the paused-take marker for the current session. */ + clearPaused: () => void; + /** Best-effort unlink of a paused partial file being discarded. */ + deletePausedFile: (fileUri: string) => void; } -export interface UseRecorderApi { +export interface UseRecorderApi { status: RecorderStatus; elapsedMs: number; permission: PermissionState; - currentRecording: Recording | null; + currentRecording: T | null; isReady: boolean; isPlaying: boolean; @@ -100,15 +111,22 @@ function mapPermissionState(response: PermissionLike): PermissionState { return 'denied'; } -export function useRecorder({ - bibleTextId, - userId, - projectId, - chapterAssignmentId, - bookCode, - chapterNumber, - verseNumber, -}: UseRecorderArgs): UseRecorderApi { +/** + * Wraps `expo-audio`'s recorder and player primitives with the state machine + * described in issue #49: Idle -> Recording -> Paused -> Review, plus + * Re-record and Delete transitions from Review. + * + * The hook owns only recording mechanics — the state machine, elapsed-time + * ticking, permissions, background auto-pause, and playback orchestration. All + * persistence, storage layout, and domain identity is injected via a + * {@link RecorderAdapter}, so the recorder is reusable across use cases. + * + * `adapter.sessionKey === null` keeps the recorder inert until a target is + * resolved. + */ +export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { + const { sessionKey } = adapter; + // Capture straight into the durable document directory (not the evictable // cache) so paused/backgrounded partial takes survive until we move or delete // them. Committed takes are relocated into the organized tree on Stop. @@ -120,23 +138,25 @@ export function useRecorder({ const [status, setStatus] = useState('idle'); const [permission, setPermission] = useState('unknown'); const [elapsedMs, setElapsedMs] = useState(0); - const [currentRecording, setCurrentRecording] = useState( - null, - ); + const [currentRecording, setCurrentRecording] = useState(null); const [isReady, setIsReady] = useState(false); + // The adapter is read via a ref inside callbacks/effects so they always see + // the latest injected closures without being re-created (and re-subscribing) + // on every render. + const adapterRef = useRef(adapter); + adapterRef.current = adapter; + // Playback is a separate concern with a one-way dependency: it takes the // reviewed take's URI and knows nothing about the recorder. The recorder // orchestrates it explicitly (stopping playback before re-record/delete). - // Stored paths are relative keys, so resolve to an absolute uri here. + // The adapter resolves a committed take to an absolute uri. const { isPlaying, toggle: togglePlaybackInternal, stop: stopPlayback, } = useDraftPlayback( - currentRecording - ? resolveRecordingUri(currentRecording.localFilePath) - : null, + currentRecording ? adapter.resolvePlaybackUri(currentRecording) : null, ); const tickRef = useRef | null>(null); @@ -146,28 +166,9 @@ export function useRecorder({ // segment's contribution is measured by wall clock via `runningSinceRef`. const baseElapsedRef = useRef(0); const runningSinceRef = useRef(null); - const bibleTextIdRef = useRef(bibleTextId); - - bibleTextIdRef.current = bibleTextId; - - // Attribution context is read via a ref so the commit callback always sees the - // latest values without being re-created (and re-subscribing) on every render. - const contextRef = useRef({ - userId, - projectId, - chapterAssignmentId, - bookCode, - chapterNumber, - verseNumber, - }); - contextRef.current = { - userId, - projectId, - chapterAssignmentId, - bookCode, - chapterNumber, - verseNumber, - }; + const sessionKeyRef = useRef(sessionKey); + + sessionKeyRef.current = sessionKey; const clearTick = useCallback(() => { if (tickRef.current) { @@ -185,7 +186,7 @@ export function useRecorder({ }, TICK_INTERVAL_MS); }, [clearTick]); - // Load latest DB recording for the current verse and reset state. + // Load the latest committed take for the current session and reset state. useEffect(() => { let cancelled = false; @@ -197,15 +198,16 @@ export function useRecorder({ startedAtRef.current = null; clearTick(); - if (bibleTextId === null) { + if (sessionKey === null) { setStatus('idle'); setCurrentRecording(null); return; } + const currentAdapter = adapterRef.current; const [latest, paused] = await Promise.all([ - getLatestRecordingForVerse(bibleTextId), - Promise.resolve(getPausedTake(bibleTextId)), + currentAdapter.loadInitial(sessionKey), + Promise.resolve(currentAdapter.loadPaused(sessionKey)), ]); if (cancelled) return; @@ -225,13 +227,13 @@ export function useRecorder({ } load().catch(error => { - log.error('Failed to load recorder state', { bibleTextId, error }); + log.error('Failed to load recorder state', { sessionKey, error }); }); return () => { cancelled = true; }; - }, [bibleTextId, clearTick]); + }, [sessionKey, clearTick]); // Ensure permission state is populated without prompting the user. useEffect(() => { @@ -268,12 +270,11 @@ export function useRecorder({ const persistPausedMarker = useCallback( (rec: AudioRecorder, elapsed: number) => { - const currentBibleTextId = bibleTextIdRef.current; - if (currentBibleTextId === null || !rec.uri) return; + const currentSessionKey = sessionKeyRef.current; + if (currentSessionKey === null || !rec.uri) return; const startedAt = startedAtRef.current ?? new Date().toISOString(); startedAtRef.current = startedAt; - setPausedTake({ - bibleTextId: currentBibleTextId, + adapterRef.current.persistPaused({ fileUri: rec.uri, elapsedMs: elapsed, startedAt, @@ -305,8 +306,8 @@ export function useRecorder({ }, [recorder, startTicking]); const start = useCallback(async () => { - const currentBibleTextId = bibleTextIdRef.current; - if (currentBibleTextId === null) return; + const currentSessionKey = sessionKeyRef.current; + if (currentSessionKey === null) return; if (permission !== 'granted') { const { granted } = await requestPermission(); if (!granted) return; @@ -315,8 +316,8 @@ export function useRecorder({ }, [permission, requestPermission, startRecordingSession]); const pauseInternal = useCallback(async () => { - const currentBibleTextId = bibleTextIdRef.current; - if (currentBibleTextId === null) return; + const currentSessionKey = sessionKeyRef.current; + if (currentSessionKey === null) return; if (status !== 'recording') return; const segmentStart = runningSinceRef.current; @@ -345,11 +346,11 @@ export function useRecorder({ }, [recorder, startTicking, status]); const commitRecording = useCallback(async () => { - const currentBibleTextId = bibleTextIdRef.current; - if (currentBibleTextId === null) return; + const currentSessionKey = sessionKeyRef.current; + if (currentSessionKey === null) return; // Compute the final duration from refs before stopping so a late tick - // or a stale `elapsedMs` render can't skew what lands in the DB. + // or a stale `elapsedMs` render can't skew what lands in storage. const segmentStart = runningSinceRef.current; const activeSegmentMs = segmentStart === null ? 0 : Date.now() - segmentStart; @@ -360,53 +361,37 @@ export function useRecorder({ const fileUri = recorder.uri; if (!fileUri) { - log.error('Recorder returned no URI on stop; skipping DB write'); + log.error('Recorder returned no URI on stop; skipping commit'); setStatus(currentRecording ? 'review' : 'idle'); return; } - const recordingId = randomUUID(); - const context = contextRef.current; - const key = buildRecordingKey({ - userId: context.userId ?? '', - projectId: context.projectId ?? 0, - bookCode: context.bookCode ?? '', - chapterNumber: context.chapterNumber ?? 0, - verseNumber: context.verseNumber ?? 0, - recordingId, - extension: extensionFromUri(fileUri), - }); - - // Move the take out of the evictable cache before recording it in the DB so - // a row never points at a file the OS could reclaim. - let moved; + let committed: T | null; try { - moved = await moveIntoStore({ sourceUri: fileUri, key }); + committed = await adapterRef.current.onCommit({ + fileUri, + durationMs: duration, + }); } catch (error) { - log.error('Failed to move recording into durable store', { - bibleTextId: currentBibleTextId, + log.error('Failed to commit recording', { + sessionKey: currentSessionKey, error, }); setStatus(currentRecording ? 'review' : 'idle'); return; } - const inserted = await insertRecording({ - id: recordingId, - bibleTextId: currentBibleTextId, - userId: context.userId ?? null, - chapterAssignmentId: context.chapterAssignmentId ?? null, - localFilePath: moved.key, - durationMs: duration, - fileSizeBytes: moved.sizeBytes, - }); + if (!committed) { + setStatus(currentRecording ? 'review' : 'idle'); + return; + } - clearPausedTake(currentBibleTextId); + adapterRef.current.clearPaused(); baseElapsedRef.current = 0; runningSinceRef.current = null; startedAtRef.current = null; setElapsedMs(duration); - setCurrentRecording(inserted); + setCurrentRecording(committed); setStatus('review'); }, [clearTick, currentRecording, recorder]); @@ -434,7 +419,7 @@ export function useRecorder({ const deleteCurrent = useCallback(async () => { if (status !== 'review' || !currentRecording) return; stopPlayback(); - await deleteRecordingById(currentRecording.id); + await adapterRef.current.deleteCommitted(currentRecording); setCurrentRecording(null); setStatus('idle'); setElapsedMs(0); @@ -443,18 +428,18 @@ export function useRecorder({ }, [currentRecording, status, stopPlayback]); const discardPaused = useCallback(async () => { - const currentBibleTextId = bibleTextIdRef.current; - if (currentBibleTextId === null) return; + const currentSessionKey = sessionKeyRef.current; + if (currentSessionKey === null) return; // Read the marker before clearing so we can unlink the durable partial file // (recorder.uri is unreliable after a process kill; the marker is not). - const marker = getPausedTake(currentBibleTextId); + const marker = adapterRef.current.loadPaused(currentSessionKey); try { await recorder.stop(); } catch (error) { log.warn('Recorder stop while discarding paused take failed', { error }); } - if (marker?.fileUri) deleteRecordingFile(marker.fileUri); - clearPausedTake(currentBibleTextId); + if (marker?.fileUri) adapterRef.current.deletePausedFile(marker.fileUri); + adapterRef.current.clearPaused(); clearTick(); baseElapsedRef.current = 0; runningSinceRef.current = null; diff --git a/src/hooks/useVerseRecorder.test.ts b/src/hooks/useVerseRecorder.test.ts new file mode 100644 index 0000000..c261941 --- /dev/null +++ b/src/hooks/useVerseRecorder.test.ts @@ -0,0 +1,287 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { AppState } from 'react-native'; +import { useVerseRecorder } from './useVerseRecorder'; + +const mockRecorder = { + currentTime: 0, + uri: 'file:///tmp/take-1.m4a', + record: jest.fn(), + pause: jest.fn(), + stop: jest.fn().mockResolvedValue(undefined), + prepareToRecordAsync: jest.fn().mockResolvedValue(undefined), +}; + +const mockInsertRecording = jest.fn(); +const mockDeleteRecordingById = jest.fn().mockResolvedValue(undefined); +const mockGetLatestRecordingForVerse = jest.fn().mockResolvedValue(null); +const mockGetPausedTake = jest.fn().mockReturnValue(null); +const mockSetPausedTake = jest.fn(); +const mockClearPausedTake = jest.fn(); +const mockBuildRecordingKey = jest.fn(); +const mockDeleteRecordingFile = jest.fn(); + +const mockPlayer = { + play: jest.fn(), + pause: jest.fn(), + seekTo: jest.fn().mockResolvedValue(undefined), + currentTime: 0, + duration: 0, +}; +let mockPlayerStatus: { playing?: boolean; didJustFinish?: boolean } = { + playing: false, + didJustFinish: false, +}; + +jest.mock('expo-crypto', () => ({ + randomUUID: jest.fn(() => 'test-uuid-1'), +})); + +jest.mock('expo-audio', () => ({ + RecordingPresets: { HIGH_QUALITY: {} }, + useAudioRecorder: () => mockRecorder, + useAudioPlayer: () => mockPlayer, + useAudioPlayerStatus: () => mockPlayerStatus, + requestRecordingPermissionsAsync: jest.fn().mockResolvedValue({ + granted: true, + canAskAgain: true, + status: 'granted', + }), + getRecordingPermissionsAsync: jest.fn().mockResolvedValue({ + granted: true, + canAskAgain: true, + status: 'granted', + }), + setAudioModeAsync: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('../db/repository', () => ({ + insertRecording: (input: unknown) => mockInsertRecording(input), + deleteRecordingById: (id: string) => mockDeleteRecordingById(id), +})); + +jest.mock('../db/queries', () => ({ + getLatestRecordingForVerse: (id: number) => + mockGetLatestRecordingForVerse(id), +})); + +const MOVED_KEY = 'recordings/u/p0/UNK/c000/v000/test-uuid-1.m4a'; + +jest.mock('../services/recordingStorage', () => ({ + buildRecordingKey: (parts: unknown) => { + mockBuildRecordingKey(parts); + return MOVED_KEY; + }, + extensionFromUri: jest.fn(() => 'm4a'), + moveIntoStore: jest.fn(async ({ key }: { key: string }) => ({ + key, + sizeBytes: 1234, + })), + resolveRecordingUri: jest.fn((pathOrKey: string) => pathOrKey), + deleteRecordingFile: (pathOrKey: string) => + mockDeleteRecordingFile(pathOrKey), +})); + +jest.mock('../services/storage', () => ({ + getPausedTake: (id: number) => mockGetPausedTake(id), + setPausedTake: (marker: unknown) => mockSetPausedTake(marker), + clearPausedTake: (id: number) => mockClearPausedTake(id), +})); + +const RESOLVE_MOCK = jest.requireMock('../services/recordingStorage') + .resolveRecordingUri as jest.Mock; + +function existingRecording(overrides: Record = {}) { + return { + id: 'existing', + bibleTextId: 42, + localFilePath: 'recordings/u/p1/MRK/c014/v001/existing.m4a', + takeNumber: 1, + isLatest: true, + syncStatus: 'pending', + createdAt: 'x', + updatedAt: 'x', + ...overrides, + }; +} + +async function waitReady(result: { current: { isReady: boolean } }) { + await waitFor(() => expect(result.current.isReady).toBe(true)); +} + +describe('useVerseRecorder', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRecorder.currentTime = 0; + mockRecorder.uri = 'file:///tmp/take-1.m4a'; + mockPlayer.currentTime = 0; + mockPlayer.duration = 0; + mockPlayerStatus = { playing: false, didJustFinish: false }; + mockGetLatestRecordingForVerse.mockResolvedValue(null); + mockGetPausedTake.mockReturnValue(null); + mockInsertRecording.mockImplementation(async input => ({ + id: input.id, + bibleTextId: input.bibleTextId, + localFilePath: input.localFilePath, + durationMs: input.durationMs ?? null, + fileSizeBytes: input.fileSizeBytes ?? null, + takeNumber: 1, + isLatest: true, + syncStatus: 'pending' as const, + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-01T00:00:00.000Z', + })); + jest.spyOn(AppState, 'addEventListener').mockReturnValue({ + remove: jest.fn(), + } as unknown as ReturnType); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('loads the latest verse recording on mount', async () => { + mockGetLatestRecordingForVerse.mockResolvedValueOnce(existingRecording()); + const { result } = renderHook(() => useVerseRecorder({ bibleTextId: 42 })); + await waitReady(result); + + expect(mockGetLatestRecordingForVerse).toHaveBeenCalledWith(42); + expect(result.current.status).toBe('review'); + expect(result.current.currentRecording?.id).toBe('existing'); + }); + + it('persists the paused marker keyed by bibleTextId', async () => { + jest.useFakeTimers(); + try { + const { result } = renderHook(() => + useVerseRecorder({ bibleTextId: 42 }), + ); + await waitReady(result); + + jest.setSystemTime(new Date('2026-07-01T00:00:00.000Z')); + await act(async () => { + await result.current.start(); + }); + + jest.setSystemTime(new Date('2026-07-01T00:00:02.500Z')); + await act(async () => { + await result.current.pause(); + }); + + expect(mockSetPausedTake).toHaveBeenCalledWith( + expect.objectContaining({ + bibleTextId: 42, + fileUri: 'file:///tmp/take-1.m4a', + elapsedMs: 2500, + }), + ); + } finally { + jest.useRealTimers(); + } + }); + + it('commits a stopped take into durable storage and the DB', async () => { + jest.useFakeTimers(); + try { + const { result } = renderHook(() => + useVerseRecorder({ + bibleTextId: 42, + userId: 'user-7', + projectId: 55, + chapterAssignmentId: 88, + bookCode: 'MRK', + chapterNumber: 14, + verseNumber: 1, + }), + ); + await waitReady(result); + + jest.setSystemTime(new Date('2026-07-01T00:00:00.000Z')); + await act(async () => { + await result.current.start(); + }); + + jest.setSystemTime(new Date('2026-07-01T00:00:03.000Z')); + await act(async () => { + await result.current.stop(); + }); + + expect(mockBuildRecordingKey).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-7', + projectId: 55, + bookCode: 'MRK', + chapterNumber: 14, + verseNumber: 1, + recordingId: 'test-uuid-1', + }), + ); + expect(mockInsertRecording).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test-uuid-1', + bibleTextId: 42, + chapterAssignmentId: 88, + localFilePath: MOVED_KEY, + durationMs: 3000, + fileSizeBytes: 1234, + }), + ); + expect(mockClearPausedTake).toHaveBeenCalledWith(42); + expect(result.current.status).toBe('review'); + } finally { + jest.useRealTimers(); + } + }); + + it('deletes the current take via the repository', async () => { + mockGetLatestRecordingForVerse.mockResolvedValueOnce(existingRecording()); + const { result } = renderHook(() => useVerseRecorder({ bibleTextId: 42 })); + await waitReady(result); + + await act(async () => { + await result.current.deleteCurrent(); + }); + + expect(mockDeleteRecordingById).toHaveBeenCalledWith('existing'); + expect(result.current.status).toBe('idle'); + expect(result.current.currentRecording).toBeNull(); + }); + + it('unlinks the durable partial file when a paused take is discarded', async () => { + mockGetPausedTake.mockReturnValue({ + bibleTextId: 42, + fileUri: 'file:///docs/partial-take.m4a', + elapsedMs: 4500, + startedAt: '2026-07-01T00:00:00.000Z', + }); + + const { result } = renderHook(() => useVerseRecorder({ bibleTextId: 42 })); + await waitReady(result); + expect(result.current.status).toBe('paused'); + + await act(async () => { + await result.current.discardPaused(); + }); + + expect(mockDeleteRecordingFile).toHaveBeenCalledWith( + 'file:///docs/partial-take.m4a', + ); + expect(mockClearPausedTake).toHaveBeenCalledWith(42); + expect(result.current.status).toBe('idle'); + }); + + it('resolves the stored key to an absolute uri for playback', async () => { + mockGetLatestRecordingForVerse.mockResolvedValueOnce(existingRecording()); + const { result } = renderHook(() => useVerseRecorder({ bibleTextId: 42 })); + await waitReady(result); + expect(result.current.status).toBe('review'); + + await act(async () => { + await result.current.togglePlayback(); + }); + + expect(RESOLVE_MOCK).toHaveBeenCalledWith( + 'recordings/u/p1/MRK/c014/v001/existing.m4a', + ); + expect(mockPlayer.play).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useVerseRecorder.ts b/src/hooks/useVerseRecorder.ts new file mode 100644 index 0000000..bdd9da4 --- /dev/null +++ b/src/hooks/useVerseRecorder.ts @@ -0,0 +1,135 @@ +import { useMemo } from 'react'; +import { randomUUID } from 'expo-crypto'; +import { deleteRecordingById, insertRecording } from '../db/repository'; +import { getLatestRecordingForVerse } from '../db/queries'; +import { + clearPausedTake, + getPausedTake, + setPausedTake, +} from '../services/storage'; +import { + buildRecordingKey, + deleteRecordingFile, + extensionFromUri, + moveIntoStore, + resolveRecordingUri, +} from '../services/recordingStorage'; +import type { Recording } from '../types/db/types'; +import { logger } from '../utils/logger'; +import type { + PausedTakeState, + RecorderAdapter, + UseRecorderApi, +} from './useRecorder'; +import { useRecorder } from './useRecorder'; + +const log = logger.create('useVerseRecorder'); + +/** + * Attribution context used to build the durable file path and persist who/where + * a take belongs to. `bibleTextId === null` keeps the recorder inert until a + * verse is resolved. The remaining fields are optional so the hook stays usable + * in isolation (e.g. tests), falling back to placeholder path segments. + */ +export interface UseVerseRecorderArgs { + bibleTextId: number | null; + userId?: string; + projectId?: number | null; + chapterAssignmentId?: number | null; + bookCode?: string | null; + chapterNumber?: number | null; + verseNumber?: number | null; +} + +export type VerseRecorderApi = UseRecorderApi; + +/** + * Adapts the generic {@link useRecorder} to the verse-recording use case: it + * wires SQLite persistence, the verse-shaped durable storage layout, and the + * paused-take marker (keyed by `bibleTextId`). The recorder itself stays + * agnostic of all of this. + */ +export function useVerseRecorder({ + bibleTextId, + userId, + projectId, + chapterAssignmentId, + bookCode, + chapterNumber, + verseNumber, +}: UseVerseRecorderArgs): VerseRecorderApi { + const adapter = useMemo>( + () => ({ + sessionKey: bibleTextId, + + loadInitial: key => getLatestRecordingForVerse(key as number), + + loadPaused: key => getPausedTake(key as number), + + persistPaused: (state: PausedTakeState) => { + if (bibleTextId === null) return; + setPausedTake({ bibleTextId, ...state }); + }, + + clearPaused: () => { + if (bibleTextId === null) return; + clearPausedTake(bibleTextId); + }, + + deletePausedFile: fileUri => deleteRecordingFile(fileUri), + + onCommit: async ({ fileUri, durationMs }) => { + if (bibleTextId === null) return null; + + const recordingId = randomUUID(); + const key = buildRecordingKey({ + userId: userId ?? '', + projectId: projectId ?? 0, + bookCode: bookCode ?? '', + chapterNumber: chapterNumber ?? 0, + verseNumber: verseNumber ?? 0, + recordingId, + extension: extensionFromUri(fileUri), + }); + + // Move the take out of the evictable cache before recording it in the + // DB so a row never points at a file the OS could reclaim. + let moved; + try { + moved = await moveIntoStore({ sourceUri: fileUri, key }); + } catch (error) { + log.error('Failed to move recording into durable store', { + bibleTextId, + error, + }); + return null; + } + + return insertRecording({ + id: recordingId, + bibleTextId, + userId: userId ?? null, + chapterAssignmentId: chapterAssignmentId ?? null, + localFilePath: moved.key, + durationMs, + fileSizeBytes: moved.sizeBytes, + }); + }, + + deleteCommitted: take => deleteRecordingById(take.id), + + resolvePlaybackUri: take => resolveRecordingUri(take.localFilePath), + }), + [ + bibleTextId, + userId, + projectId, + chapterAssignmentId, + bookCode, + chapterNumber, + verseNumber, + ], + ); + + return useRecorder(adapter); +} From f2e3a6e70b7ac5410317cc0df5f9ba87f93e9009 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 19:26:58 -0300 Subject: [PATCH 20/55] [#49]: Resolve playback uri through the adapter ref Read the committed take's playback uri via adapterRef.current instead of the adapter prop, matching every other adapter call in useRecorder. This keeps the ref indirection consistent so the hook never depends on a fresh adapter object reference when handing a uri to useDraftPlayback. Refs #49 Co-authored-by: Cursor --- src/hooks/useRecorder.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts index 5bca7c8..7afb066 100644 --- a/src/hooks/useRecorder.ts +++ b/src/hooks/useRecorder.ts @@ -156,7 +156,9 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { toggle: togglePlaybackInternal, stop: stopPlayback, } = useDraftPlayback( - currentRecording ? adapter.resolvePlaybackUri(currentRecording) : null, + currentRecording + ? adapterRef.current.resolvePlaybackUri(currentRecording) + : null, ); const tickRef = useRef | null>(null); From e14e51e3b1c08e71bd6eb472fc4f020bcfa5bf45 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Thu, 2 Jul 2026 19:38:59 -0300 Subject: [PATCH 21/55] [#49]: Defer Record tab paint to avoid an idle-to-review flash The recorder loads its state from the local DB asynchronously, so the tab briefly rendered the idle "Record" button before snapping to review when an existing take resolved. Hold the record/review UI back until the recorder is ready (capped at 100ms so a slow load still surfaces), showing a height-reserved placeholder meanwhile so the layout does not shift. Refs #49 Co-authored-by: Cursor --- src/app/tabs/drafting/RecordTab.test.tsx | 36 ++ src/app/tabs/drafting/RecordTab.tsx | 479 ++++++++++++----------- 2 files changed, 293 insertions(+), 222 deletions(-) diff --git a/src/app/tabs/drafting/RecordTab.test.tsx b/src/app/tabs/drafting/RecordTab.test.tsx index 32955b1..81dc353 100644 --- a/src/app/tabs/drafting/RecordTab.test.tsx +++ b/src/app/tabs/drafting/RecordTab.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Alert } from 'react-native'; import { + act, fireEvent, render, screen, @@ -86,6 +87,41 @@ describe('RecordTab', () => { jest.clearAllMocks(); }); + it('defers the record UI until the recorder is ready to avoid a flash', () => { + jest.useFakeTimers(); + try { + mockUseVerseRecorder.mockReturnValue({ + ...baseRecorderState(), + isReady: false, + }); + renderTab(); + + // While loading, the controls are held back so idle never flashes. + expect(screen.getByTestId('record-loading')).toBeTruthy(); + expect(screen.queryByTestId('record-start-button')).toBeNull(); + // The verse reference still shows so the header does not jump. + expect(screen.getByTestId('record-verse-reference')).toHaveTextContent( + 'Mark 14:3', + ); + + // After the defer window the UI surfaces even if still not ready. + act(() => { + jest.advanceTimersByTime(100); + }); + expect(screen.getByTestId('record-start-button')).toBeTruthy(); + } finally { + jest.useRealTimers(); + } + }); + + it('renders the record UI immediately once the recorder is ready', () => { + mockUseVerseRecorder.mockReturnValue(baseRecorderState()); + renderTab(); + + expect(screen.queryByTestId('record-loading')).toBeNull(); + expect(screen.getByTestId('record-start-button')).toBeTruthy(); + }); + it('renders the idle record button with a label and disabled play hint', () => { mockUseVerseRecorder.mockReturnValue(baseRecorderState()); renderTab(); diff --git a/src/app/tabs/drafting/RecordTab.tsx b/src/app/tabs/drafting/RecordTab.tsx index 01ad418..cbf20b6 100644 --- a/src/app/tabs/drafting/RecordTab.tsx +++ b/src/app/tabs/drafting/RecordTab.tsx @@ -34,6 +34,12 @@ if (Platform.OS === 'android') { const LIVE_WAVEFORM_BARS = 22; +// Hold the record/review UI back for a short beat on mount and verse switches. +// The recording state is loaded from the local DB (a few ms), so deferring the +// first paint lets it resolve first — the UI settles straight into the correct +// state instead of flashing "idle" and then snapping to "review". +const PRESENTATION_DEFER_MS = 100; + interface RecordTabProps { bookName: string; chapterNumber: number; @@ -85,6 +91,21 @@ export function RecordTab({ verseNumber: selectedVerseNumber, }); const [sourceExpanded, setSourceExpanded] = useState(false); + const [deferElapsed, setDeferElapsed] = useState(false); + + // Present as soon as the recorder has loaded its state, but cap the wait at + // PRESENTATION_DEFER_MS so a slow/stalled load still surfaces the UI. + const showContent = recorder.isReady || deferElapsed; + + useEffect(() => { + if (recorder.isReady) return; + setDeferElapsed(false); + const timer = setTimeout( + () => setDeferElapsed(true), + PRESENTATION_DEFER_MS, + ); + return () => clearTimeout(timer); + }, [recorder.isReady]); const currentReference = verseReference( bookName, @@ -291,242 +312,251 @@ export function RecordTab({ - {recorder.status === 'recording' || recorder.status === 'paused' ? ( - - {Array.from({ length: LIVE_WAVEFORM_BARS }).map((_, index) => { - const active = recorder.status === 'recording'; - const height = - 14 + - ((Math.abs(Math.sin((recorder.elapsedMs + index * 40) / 90)) * - 48) | - 0); - return ( - - ); - })} - - ) : recorder.status === 'review' ? ( - - {Array.from({ length: LIVE_WAVEFORM_BARS }).map((_, index) => ( + {!showContent ? ( + + ) : ( + <> + {recorder.status === 'recording' || recorder.status === 'paused' ? ( - ))} - - ) : null} - - - {recorder.status === 'idle' && ( - - - - - - Record {currentReference} - + {Array.from({ length: LIVE_WAVEFORM_BARS }).map((_, index) => { + const active = recorder.status === 'recording'; + const height = + 14 + + ((Math.abs(Math.sin((recorder.elapsedMs + index * 40) / 90)) * + 48) | + 0); + return ( + + ); + })} + + ) : recorder.status === 'review' ? ( - - - - )} - - {recorder.status === 'recording' && ( - - - {formatDuration(recorder.elapsedMs)} - - - recorder.stop()} - accessibilityRole="button" - accessibilityLabel="Stop recording" - testID="record-stop-button" - > - - - recorder.pause()} - accessibilityRole="button" - accessibilityLabel="Pause recording" - testID="record-pause-button" - > - - - - - Tap pause to study the source, stop to finish. - - - )} - - {recorder.status === 'paused' && ( - - - {formatDuration(recorder.elapsedMs)} - - - recorder.stop()} - accessibilityRole="button" - accessibilityLabel="Stop recording" - testID="record-stop-button" - > - ( + - - recorder.resume()} - accessibilityRole="button" - accessibilityLabel="Resume recording" - testID="record-resume-button" - > - - + ))} - - Recording paused — review the source below, then resume. - - - )} + ) : null} - {recorder.status === 'review' && ( - - - - - - { - recorder.togglePlayback(); - }} - accessibilityRole="button" - accessibilityLabel={ - recorder.isPlaying ? 'Pause draft' : 'Play draft' - } - accessibilityState={{ selected: recorder.isPlaying }} - testID="record-play-button" - > - {recorder.isPlaying ? ( - + {recorder.status === 'idle' && ( + + + - ) : ( + + + Record {currentReference} + + - )} - - - - - - Re-record - - - - Delete - - + + + )} + + {recorder.status === 'recording' && ( + + + {formatDuration(recorder.elapsedMs)} + + + recorder.stop()} + accessibilityRole="button" + accessibilityLabel="Stop recording" + testID="record-stop-button" + > + + + recorder.pause()} + accessibilityRole="button" + accessibilityLabel="Pause recording" + testID="record-pause-button" + > + + + + + Tap pause to study the source, stop to finish. + + + )} + + {recorder.status === 'paused' && ( + + + {formatDuration(recorder.elapsedMs)} + + + recorder.stop()} + accessibilityRole="button" + accessibilityLabel="Stop recording" + testID="record-stop-button" + > + + + recorder.resume()} + accessibilityRole="button" + accessibilityLabel="Resume recording" + testID="record-resume-button" + > + + + + + Recording paused — review the source below, then resume. + + + )} + + {recorder.status === 'review' && ( + + + + + + { + recorder.togglePlayback(); + }} + accessibilityRole="button" + accessibilityLabel={ + recorder.isPlaying ? 'Pause draft' : 'Play draft' + } + accessibilityState={{ selected: recorder.isPlaying }} + testID="record-play-button" + > + {recorder.isPlaying ? ( + + ) : ( + + )} + + + + + + Re-record + + + + Delete + + + + )} - )} - + + )} Date: Fri, 3 Jul 2026 00:02:51 -0300 Subject: [PATCH 22/55] [#49]: Reorganize Record tab into a record module Split the monolithic Record tab into components, hooks, and utils under drafting/record and colocate useVerseRecorder with its screen. Refs #49 Co-authored-by: Cursor --- src/app/tabs/drafting/DraftingPage.tsx | 2 +- src/app/tabs/drafting/RecordTab.tsx | 777 ------------------ .../drafting/{ => record}/RecordTab.test.tsx | 160 +--- src/app/tabs/drafting/record/RecordTab.tsx | 188 +++++ .../components/RecordingControls.test.tsx | 184 +++++ .../record/components/RecordingControls.tsx | 347 ++++++++ .../components/RecordingWaveform.test.tsx | 32 + .../record/components/RecordingWaveform.tsx | 90 ++ .../components/SourceTextPanel.test.tsx | 15 + .../record/components/SourceTextPanel.tsx | 88 ++ .../record/components/VerseNav.test.tsx | 82 ++ .../drafting/record/components/VerseNav.tsx | 88 ++ .../record/hooks/useRecordTabGuards.test.ts | 146 ++++ .../record/hooks/useRecordTabGuards.ts | 117 +++ .../record}/hooks/useVerseRecorder.test.ts | 13 +- .../record}/hooks/useVerseRecorder.ts | 19 +- .../record/utils/recordTabUtils.test.ts | 21 + .../drafting/record/utils/recordTabUtils.ts | 18 + 18 files changed, 1457 insertions(+), 930 deletions(-) delete mode 100644 src/app/tabs/drafting/RecordTab.tsx rename src/app/tabs/drafting/{ => record}/RecordTab.test.tsx (65%) create mode 100644 src/app/tabs/drafting/record/RecordTab.tsx create mode 100644 src/app/tabs/drafting/record/components/RecordingControls.test.tsx create mode 100644 src/app/tabs/drafting/record/components/RecordingControls.tsx create mode 100644 src/app/tabs/drafting/record/components/RecordingWaveform.test.tsx create mode 100644 src/app/tabs/drafting/record/components/RecordingWaveform.tsx create mode 100644 src/app/tabs/drafting/record/components/SourceTextPanel.test.tsx create mode 100644 src/app/tabs/drafting/record/components/SourceTextPanel.tsx create mode 100644 src/app/tabs/drafting/record/components/VerseNav.test.tsx create mode 100644 src/app/tabs/drafting/record/components/VerseNav.tsx create mode 100644 src/app/tabs/drafting/record/hooks/useRecordTabGuards.test.ts create mode 100644 src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts rename src/{ => app/tabs/drafting/record}/hooks/useVerseRecorder.test.ts (96%) rename src/{ => app/tabs/drafting/record}/hooks/useVerseRecorder.ts (88%) create mode 100644 src/app/tabs/drafting/record/utils/recordTabUtils.test.ts create mode 100644 src/app/tabs/drafting/record/utils/recordTabUtils.ts diff --git a/src/app/tabs/drafting/DraftingPage.tsx b/src/app/tabs/drafting/DraftingPage.tsx index 5ba57f2..abaa543 100644 --- a/src/app/tabs/drafting/DraftingPage.tsx +++ b/src/app/tabs/drafting/DraftingPage.tsx @@ -16,7 +16,7 @@ import { logger } from '../../../utils/logger'; import { BibleTab } from './BibleTab'; import { ChapterAudioPlayerBar } from './ChapterAudioPlayerBar'; import { DraftingTab, DraftingTabBar } from './DraftingTabBar'; -import { RecordTab } from './RecordTab'; +import { RecordTab } from './record/RecordTab'; const log = logger.create('DraftingPage'); diff --git a/src/app/tabs/drafting/RecordTab.tsx b/src/app/tabs/drafting/RecordTab.tsx deleted file mode 100644 index cbf20b6..0000000 --- a/src/app/tabs/drafting/RecordTab.tsx +++ /dev/null @@ -1,777 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { - Alert, - LayoutAnimation, - Linking, - Platform, - StyleSheet, - Text, - TouchableOpacity, - UIManager, - View, -} from 'react-native'; -import { - ChevronDown, - ChevronLeft, - ChevronRight, - ChevronUp, - CircleDot, - Pause, - Play, - RefreshCw, - Square, - Trash2, -} from 'lucide-react-native'; -import { useNavigation } from '@react-navigation/native'; -import { theme } from '../../../theme'; -import { iconSizes, listIconStrokeWidth } from '../../../theme/iconSpecs'; -import type { VerseData } from '../../../types/db/types'; -import { useVerseRecorder } from '../../../hooks/useVerseRecorder'; - -if (Platform.OS === 'android') { - UIManager.setLayoutAnimationEnabledExperimental?.(true); -} - -const LIVE_WAVEFORM_BARS = 22; - -// Hold the record/review UI back for a short beat on mount and verse switches. -// The recording state is loaded from the local DB (a few ms), so deferring the -// first paint lets it resolve first — the UI settles straight into the correct -// state instead of flashing "idle" and then snapping to "review". -const PRESENTATION_DEFER_MS = 100; - -interface RecordTabProps { - bookName: string; - chapterNumber: number; - verses: VerseData[]; - selectedVerseNumber: number; - bibleTextIdForSelectedVerse: number | null; - onSelectVerse: (verseNumber: number) => void; - userId?: string; - projectId?: number | null; - chapterAssignmentId?: number | null; - bookCode?: string | null; -} - -function formatDuration(ms: number): string { - const safeMs = ms < 0 ? 0 : ms; - const totalCentis = Math.floor(safeMs / 10); - const totalSeconds = Math.floor(totalCentis / 100); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - const centis = totalCentis % 100; - const pad = (value: number) => value.toString().padStart(2, '0'); - return `${pad(minutes)}:${pad(seconds)}:${pad(centis)}`; -} - -function verseReference(bookName: string, chapter: number, verse: number) { - return `${bookName} ${chapter}:${verse}`; -} - -export function RecordTab({ - bookName, - chapterNumber, - verses, - selectedVerseNumber, - bibleTextIdForSelectedVerse, - onSelectVerse, - userId, - projectId, - chapterAssignmentId, - bookCode, -}: RecordTabProps) { - const navigation = useNavigation(); - const recorder = useVerseRecorder({ - bibleTextId: bibleTextIdForSelectedVerse, - userId, - projectId, - chapterAssignmentId, - bookCode, - chapterNumber, - verseNumber: selectedVerseNumber, - }); - const [sourceExpanded, setSourceExpanded] = useState(false); - const [deferElapsed, setDeferElapsed] = useState(false); - - // Present as soon as the recorder has loaded its state, but cap the wait at - // PRESENTATION_DEFER_MS so a slow/stalled load still surfaces the UI. - const showContent = recorder.isReady || deferElapsed; - - useEffect(() => { - if (recorder.isReady) return; - setDeferElapsed(false); - const timer = setTimeout( - () => setDeferElapsed(true), - PRESENTATION_DEFER_MS, - ); - return () => clearTimeout(timer); - }, [recorder.isReady]); - - const currentReference = verseReference( - bookName, - chapterNumber, - selectedVerseNumber, - ); - - const verseIndex = verses.findIndex( - v => v.verseNumber === selectedVerseNumber, - ); - const canGoPrev = verseIndex > 0; - const canGoNext = verseIndex >= 0 && verseIndex < verses.length - 1; - // Verse navigation is locked mid-recording so the take stays anchored to - // the verse the user started on. Paused/idle/review keep their own guards. - const isRecordingLocked = recorder.status === 'recording'; - const prevDisabled = !canGoPrev || isRecordingLocked; - const nextDisabled = !canGoNext || isRecordingLocked; - - const sourceText = useMemo( - () => verses.find(v => v.verseNumber === selectedVerseNumber)?.text ?? '', - [verses, selectedVerseNumber], - ); - - useEffect(() => { - if (recorder.status !== 'paused') return; - - const beforeRemove = (event: { - preventDefault: () => void; - data: { action: unknown }; - }) => { - event.preventDefault(); - Alert.alert( - 'Recording in progress', - 'You have a paused recording. Resume it or discard the take before leaving.', - [ - { text: 'Resume', style: 'cancel' }, - { - text: 'Discard', - style: 'destructive', - onPress: async () => { - await recorder.discardPaused(); - // Re-dispatch original navigation action after discarding. - ( - navigation as unknown as { dispatch: (a: unknown) => void } - ).dispatch(event.data.action); - }, - }, - ], - ); - }; - - const unsubscribe = ( - navigation as unknown as { - addListener: ( - event: 'beforeRemove', - cb: typeof beforeRemove, - ) => () => void; - } - ).addListener('beforeRemove', beforeRemove); - - return unsubscribe; - }, [navigation, recorder]); - - function withPausedGuard(action: () => void) { - if (recorder.status === 'paused') { - Alert.alert( - 'Recording in progress', - 'Resume or discard the paused take before switching verses.', - [ - { text: 'Resume', style: 'cancel' }, - { - text: 'Discard', - style: 'destructive', - onPress: async () => { - await recorder.discardPaused(); - action(); - }, - }, - ], - ); - return; - } - action(); - } - - function handlePrev() { - if (prevDisabled) return; - recorder.stopPlayback(); - withPausedGuard(() => onSelectVerse(verses[verseIndex - 1]!.verseNumber)); - } - - function handleNext() { - if (nextDisabled) return; - recorder.stopPlayback(); - withPausedGuard(() => onSelectVerse(verses[verseIndex + 1]!.verseNumber)); - } - - function toggleSource() { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - setSourceExpanded(prev => !prev); - } - - function showMicBlockedAlert() { - Alert.alert( - 'Microphone access required', - 'Fluent needs microphone access to record verses. Enable it in Settings.', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Go to Settings', - onPress: () => { - Linking.openSettings().catch(() => undefined); - }, - }, - ], - ); - } - - /** - * Ensures microphone permission is granted, prompting the OS dialog when - * possible and only surfacing the Settings deep-link when the OS suppresses - * the prompt (permanent denial). Returns whether recording may proceed. - */ - async function ensureMicPermission(): Promise { - if (recorder.permission === 'granted') return true; - if (recorder.permission === 'blocked') { - showMicBlockedAlert(); - return false; - } - const { granted, canAskAgain } = await recorder.requestPermission(); - if (granted) return true; - if (!canAskAgain) showMicBlockedAlert(); - return false; - } - - async function handleStartPress() { - if (!(await ensureMicPermission())) return; - await recorder.start(); - } - - async function handleReRecordPress() { - if (!(await ensureMicPermission())) return; - await recorder.reRecord(); - } - - async function handleDeletePress() { - Alert.alert('Delete draft?', 'This cannot be undone.', [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - await recorder.deleteCurrent(); - }, - }, - ]); - } - - return ( - - - - - - - {currentReference} - - - - - - - {!showContent ? ( - - ) : ( - <> - {recorder.status === 'recording' || recorder.status === 'paused' ? ( - - {Array.from({ length: LIVE_WAVEFORM_BARS }).map((_, index) => { - const active = recorder.status === 'recording'; - const height = - 14 + - ((Math.abs(Math.sin((recorder.elapsedMs + index * 40) / 90)) * - 48) | - 0); - return ( - - ); - })} - - ) : recorder.status === 'review' ? ( - - {Array.from({ length: LIVE_WAVEFORM_BARS }).map((_, index) => ( - - ))} - - ) : null} - - - {recorder.status === 'idle' && ( - - - - - - Record {currentReference} - - - - - - )} - - {recorder.status === 'recording' && ( - - - {formatDuration(recorder.elapsedMs)} - - - recorder.stop()} - accessibilityRole="button" - accessibilityLabel="Stop recording" - testID="record-stop-button" - > - - - recorder.pause()} - accessibilityRole="button" - accessibilityLabel="Pause recording" - testID="record-pause-button" - > - - - - - Tap pause to study the source, stop to finish. - - - )} - - {recorder.status === 'paused' && ( - - - {formatDuration(recorder.elapsedMs)} - - - recorder.stop()} - accessibilityRole="button" - accessibilityLabel="Stop recording" - testID="record-stop-button" - > - - - recorder.resume()} - accessibilityRole="button" - accessibilityLabel="Resume recording" - testID="record-resume-button" - > - - - - - Recording paused — review the source below, then resume. - - - )} - - {recorder.status === 'review' && ( - - - - - - { - recorder.togglePlayback(); - }} - accessibilityRole="button" - accessibilityLabel={ - recorder.isPlaying ? 'Pause draft' : 'Play draft' - } - accessibilityState={{ selected: recorder.isPlaying }} - testID="record-play-button" - > - {recorder.isPlaying ? ( - - ) : ( - - )} - - - - - - Re-record - - - - Delete - - - - )} - - - )} - - - - {sourceExpanded ? 'Hide source text' : 'Show source text'} - - {sourceExpanded ? ( - - ) : ( - - )} - - {sourceExpanded && ( - - {sourceText} - - )} - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - padding: theme.spacing.lg, - gap: theme.spacing.lg, - }, - verseNav: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-around', - }, - verseNavButton: { - padding: theme.spacing.sm, - }, - verseReference: { - fontSize: theme.typography.sizes.xl, - fontWeight: theme.typography.weights.bold, - color: theme.colors.foreground, - }, - disabledIcon: { - opacity: 0.35, - }, - waveform: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - height: 72, - width: '50%', - alignSelf: 'center', - }, - waveformBarLive: { - width: 5, - borderRadius: 2.5, - backgroundColor: theme.colors.recordAccent, - }, - waveformBarStatic: { - width: 5, - borderRadius: 2.5, - backgroundColor: theme.colors.primary, - }, - controls: { - alignItems: 'center', - gap: theme.spacing.md, - }, - // Reserves roughly the waveform + controls height so deferring the first - // paint does not shift the surrounding layout when the real UI appears. - deferPlaceholder: { - minHeight: 232, - }, - idleGroup: { - alignItems: 'center', - gap: theme.spacing.md, - }, - recordButtonCircle: { - width: 88, - height: 88, - borderRadius: theme.radius.full, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: theme.colors.recordAccent, - }, - recordButtonLabel: { - color: theme.colors.foreground, - fontSize: theme.typography.sizes.md, - fontWeight: theme.typography.weights.medium, - textAlign: 'center', - }, - mutedPlaceholderCircle: { - width: 48, - height: 48, - borderRadius: theme.radius.full, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: theme.colors.cardBackground, - opacity: 0.6, - }, - captureGroup: { - alignItems: 'center', - gap: theme.spacing.md, - }, - captureButtonsRow: { - flexDirection: 'row', - alignItems: 'center', - gap: theme.spacing.lg, - }, - captureTip: { - fontSize: theme.typography.sizes.sm, - color: theme.colors.mutedForeground, - textAlign: 'center', - }, - duration: { - fontSize: 32, - fontWeight: theme.typography.weights.medium, - fontVariant: ['tabular-nums'], - color: theme.colors.mutedForeground, - }, - stopCircleButton: { - width: 56, - height: 56, - borderRadius: theme.radius.full, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: theme.colors.cardBackground, - borderWidth: 1, - borderColor: theme.colors.border, - }, - primaryActionCircle: { - width: 72, - height: 72, - borderRadius: theme.radius.full, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: theme.colors.recordAccent, - }, - reviewGroup: { - alignItems: 'center', - gap: theme.spacing.lg, - }, - primaryPlayCircle: { - width: 72, - height: 72, - borderRadius: theme.radius.full, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: theme.colors.primary, - }, - reviewActions: { - flexDirection: 'row', - gap: theme.spacing.md, - }, - reRecordButton: { - flexDirection: 'row', - alignItems: 'center', - gap: theme.spacing.sm, - paddingHorizontal: theme.spacing.lg, - paddingVertical: theme.spacing.md, - borderRadius: theme.radius.md, - backgroundColor: theme.colors.cardBackground, - }, - reRecordLabel: { - color: theme.colors.foreground, - fontSize: theme.typography.sizes.md, - fontWeight: theme.typography.weights.medium, - }, - destructiveButton: { - flexDirection: 'row', - alignItems: 'center', - gap: theme.spacing.sm, - paddingHorizontal: theme.spacing.lg, - paddingVertical: theme.spacing.md, - borderRadius: theme.radius.md, - borderWidth: 1, - borderColor: theme.colors.destructive, - backgroundColor: theme.colors.background, - }, - destructiveLabel: { - color: theme.colors.destructive, - fontSize: theme.typography.sizes.md, - fontWeight: theme.typography.weights.medium, - }, - accordionHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - padding: theme.spacing.md, - backgroundColor: theme.colors.cardBackground, - borderRadius: theme.radius.md, - }, - accordionLabel: { - fontSize: theme.typography.sizes.sm, - fontWeight: theme.typography.weights.medium, - color: theme.colors.foreground, - }, - sourceBody: { - padding: theme.spacing.md, - }, - sourceText: { - fontSize: theme.typography.sizes.md, - lineHeight: theme.typography.lineHeights.normal, - color: theme.colors.foreground, - }, -}); diff --git a/src/app/tabs/drafting/RecordTab.test.tsx b/src/app/tabs/drafting/record/RecordTab.test.tsx similarity index 65% rename from src/app/tabs/drafting/RecordTab.test.tsx rename to src/app/tabs/drafting/record/RecordTab.test.tsx index 81dc353..3e4daba 100644 --- a/src/app/tabs/drafting/RecordTab.test.tsx +++ b/src/app/tabs/drafting/record/RecordTab.test.tsx @@ -8,7 +8,7 @@ import { waitFor, } from '@testing-library/react-native'; import { RecordTab } from './RecordTab'; -import type { VerseData } from '../../../types/db/types'; +import type { VerseData } from '../../../../types/db/types'; const mockUseVerseRecorder = jest.fn(); @@ -20,7 +20,7 @@ jest.mock('@react-navigation/native', () => ({ }), })); -jest.mock('../../../hooks/useVerseRecorder', () => ({ +jest.mock('./hooks/useVerseRecorder', () => ({ useVerseRecorder: (args: unknown) => mockUseVerseRecorder(args), })); @@ -96,15 +96,12 @@ describe('RecordTab', () => { }); renderTab(); - // While loading, the controls are held back so idle never flashes. expect(screen.getByTestId('record-loading')).toBeTruthy(); expect(screen.queryByTestId('record-start-button')).toBeNull(); - // The verse reference still shows so the header does not jump. expect(screen.getByTestId('record-verse-reference')).toHaveTextContent( 'Mark 14:3', ); - // After the defer window the UI surfaces even if still not ready. act(() => { jest.advanceTimersByTime(100); }); @@ -122,130 +119,6 @@ describe('RecordTab', () => { expect(screen.getByTestId('record-start-button')).toBeTruthy(); }); - it('renders the idle record button with a label and disabled play hint', () => { - mockUseVerseRecorder.mockReturnValue(baseRecorderState()); - renderTab(); - - expect(screen.getByTestId('record-verse-reference')).toHaveTextContent( - 'Mark 14:3', - ); - expect(screen.getByTestId('record-start-button')).toBeTruthy(); - expect(screen.getByTestId('record-start-label')).toHaveTextContent( - 'Record Mark 14:3', - ); - expect(screen.getByTestId('record-play-idle-placeholder')).toBeTruthy(); - }); - - it('shows pause and stop controls with a duration and tip when recording', () => { - mockUseVerseRecorder.mockReturnValue({ - ...baseRecorderState(), - status: 'recording', - elapsedMs: 65_420, - }); - renderTab(); - - expect(screen.getByTestId('record-duration')).toHaveTextContent('01:05:42'); - expect(screen.getByTestId('record-pause-button')).toBeTruthy(); - expect(screen.getByTestId('record-stop-button')).toBeTruthy(); - expect(screen.getByTestId('record-tip')).toHaveTextContent( - 'Tap pause to study the source, stop to finish.', - ); - }); - - it('shows the paused tip instead of the recording tip while paused', () => { - mockUseVerseRecorder.mockReturnValue({ - ...baseRecorderState(), - status: 'paused', - elapsedMs: 3_000, - }); - renderTab(); - - expect(screen.getByTestId('record-tip')).toHaveTextContent( - 'Recording paused — review the source below, then resume.', - ); - }); - - it('shows the resume button while paused', () => { - mockUseVerseRecorder.mockReturnValue({ - ...baseRecorderState(), - status: 'paused', - elapsedMs: 3_000, - }); - renderTab(); - - expect(screen.getByTestId('record-resume-button')).toBeTruthy(); - expect(screen.queryByTestId('record-pause-button')).toBeNull(); - }); - - it('renders review controls with placeholder, play, re-record and delete', () => { - const state = { - ...baseRecorderState(), - status: 'review' as const, - currentRecording: reviewRecording(), - }; - mockUseVerseRecorder.mockReturnValue(state); - - const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); - renderTab(); - - expect( - screen.getByTestId('record-review-record-done-placeholder'), - ).toBeTruthy(); - expect(screen.getByTestId('record-play-button')).toBeTruthy(); - expect(screen.getByTestId('record-rerecord-button')).toHaveTextContent( - 'Re-record', - ); - expect(screen.getByTestId('record-delete-button')).toHaveTextContent( - 'Delete', - ); - expect(screen.queryByTestId('record-tip')).toBeNull(); - - fireEvent.press(screen.getByTestId('record-delete-button')); - expect(alertSpy).toHaveBeenCalledWith( - 'Delete draft?', - expect.any(String), - expect.any(Array), - ); - - alertSpy.mockRestore(); - }); - - it('plays the draft when the review play button is pressed', () => { - const togglePlayback = jest.fn().mockResolvedValue(undefined); - mockUseVerseRecorder.mockReturnValue({ - ...baseRecorderState(), - status: 'review', - currentRecording: reviewRecording(), - isPlaying: false, - togglePlayback, - }); - - renderTab(); - - expect( - screen.getByTestId('record-play-button').props.accessibilityLabel, - ).toBe('Play draft'); - fireEvent.press(screen.getByTestId('record-play-button')); - expect(togglePlayback).toHaveBeenCalledTimes(1); - }); - - it('shows a pause affordance while the draft is playing', () => { - mockUseVerseRecorder.mockReturnValue({ - ...baseRecorderState(), - status: 'review', - currentRecording: reviewRecording(), - isPlaying: true, - }); - - renderTab(); - - const playButton = screen.getByTestId('record-play-button'); - expect(playButton.props.accessibilityLabel).toBe('Pause draft'); - expect(playButton.props.accessibilityState).toEqual( - expect.objectContaining({ selected: true }), - ); - }); - it('disables verse chevrons while recording even when navigation is possible', () => { mockUseVerseRecorder.mockReturnValue({ ...baseRecorderState(), @@ -364,6 +237,26 @@ describe('RecordTab', () => { alertSpy.mockRestore(); }); + it('shows a delete confirmation alert from the review controls', () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + mockUseVerseRecorder.mockReturnValue({ + ...baseRecorderState(), + status: 'review', + currentRecording: reviewRecording(), + }); + + renderTab(); + fireEvent.press(screen.getByTestId('record-delete-button')); + + expect(alertSpy).toHaveBeenCalledWith( + 'Delete draft?', + expect.any(String), + expect.any(Array), + ); + + alertSpy.mockRestore(); + }); + it('forwards attribution context to useVerseRecorder', () => { mockUseVerseRecorder.mockReturnValue(baseRecorderState()); renderTab({ @@ -386,13 +279,4 @@ describe('RecordTab', () => { }), ); }); - - it('source text accordion is collapsed by default and toggles open', () => { - mockUseVerseRecorder.mockReturnValue(baseRecorderState()); - renderTab(); - - expect(screen.queryByTestId('record-source-body')).toBeNull(); - fireEvent.press(screen.getByTestId('record-source-toggle')); - expect(screen.getByTestId('record-source-body')).toHaveTextContent('v3'); - }); }); diff --git a/src/app/tabs/drafting/record/RecordTab.tsx b/src/app/tabs/drafting/record/RecordTab.tsx new file mode 100644 index 0000000..90f1796 --- /dev/null +++ b/src/app/tabs/drafting/record/RecordTab.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, StyleSheet, View } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { theme } from '../../../../theme'; +import type { VerseData } from '../../../../types/db/types'; +import { RecordingControls } from './components/RecordingControls'; +import { RecordingWaveform } from './components/RecordingWaveform'; +import { SourceTextPanel } from './components/SourceTextPanel'; +import { VerseNav } from './components/VerseNav'; +import { useRecordTabGuards } from './hooks/useRecordTabGuards'; +import { useVerseRecorder } from './hooks/useVerseRecorder'; +import { verseReference } from './utils/recordTabUtils'; + +// Hold the record/review UI back for a short beat on mount and verse switches. +// The recording state is loaded from the local DB (a few ms), so deferring the +// first paint lets it resolve first — the UI settles straight into the correct +// state instead of flashing "idle" and then snapping to "review". +const PRESENTATION_DEFER_MS = 100; + +interface RecordTabProps { + bookName: string; + chapterNumber: number; + verses: VerseData[]; + selectedVerseNumber: number; + bibleTextIdForSelectedVerse: number | null; + onSelectVerse: (verseNumber: number) => void; + userId?: string; + projectId?: number | null; + chapterAssignmentId?: number | null; + bookCode?: string | null; +} + +export function RecordTab({ + bookName, + chapterNumber, + verses, + selectedVerseNumber, + bibleTextIdForSelectedVerse, + onSelectVerse, + userId, + projectId, + chapterAssignmentId, + bookCode, +}: RecordTabProps) { + const navigation = useNavigation(); + const recorder = useVerseRecorder({ + bibleTextId: bibleTextIdForSelectedVerse, + userId, + projectId, + chapterAssignmentId, + bookCode, + chapterNumber, + verseNumber: selectedVerseNumber, + }); + const [deferElapsed, setDeferElapsed] = useState(false); + + const { withPausedGuard, ensureMicPermission } = useRecordTabGuards({ + status: recorder.status, + permission: recorder.permission, + requestPermission: recorder.requestPermission, + discardPaused: recorder.discardPaused, + navigation: navigation as unknown as Parameters< + typeof useRecordTabGuards + >[0]['navigation'], + }); + + // Present as soon as the recorder has loaded its state, but cap the wait at + // PRESENTATION_DEFER_MS so a slow/stalled load still surfaces the UI. + const showContent = recorder.isReady || deferElapsed; + + useEffect(() => { + if (recorder.isReady) return; + setDeferElapsed(false); + const timer = setTimeout( + () => setDeferElapsed(true), + PRESENTATION_DEFER_MS, + ); + return () => clearTimeout(timer); + }, [recorder.isReady]); + + const currentReference = verseReference( + bookName, + chapterNumber, + selectedVerseNumber, + ); + + const verseIndex = verses.findIndex( + v => v.verseNumber === selectedVerseNumber, + ); + const canGoPrev = verseIndex > 0; + const canGoNext = verseIndex >= 0 && verseIndex < verses.length - 1; + // Verse navigation is locked mid-recording so the take stays anchored to + // the verse the user started on. Paused/idle/review keep their own guards. + const isRecordingLocked = recorder.status === 'recording'; + const prevDisabled = !canGoPrev || isRecordingLocked; + const nextDisabled = !canGoNext || isRecordingLocked; + + const sourceText = useMemo( + () => verses.find(v => v.verseNumber === selectedVerseNumber)?.text ?? '', + [verses, selectedVerseNumber], + ); + + function handlePrev() { + if (prevDisabled) return; + recorder.stopPlayback(); + withPausedGuard(() => onSelectVerse(verses[verseIndex - 1]!.verseNumber)); + } + + function handleNext() { + if (nextDisabled) return; + recorder.stopPlayback(); + withPausedGuard(() => onSelectVerse(verses[verseIndex + 1]!.verseNumber)); + } + + async function handleStartPress() { + if (!(await ensureMicPermission())) return; + await recorder.start(); + } + + async function handleReRecordPress() { + if (!(await ensureMicPermission())) return; + await recorder.reRecord(); + } + + function handleDeletePress() { + Alert.alert('Delete draft?', 'This cannot be undone.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + await recorder.deleteCurrent(); + }, + }, + ]); + } + + return ( + + + + {!showContent ? ( + + ) : ( + <> + + recorder.pause()} + onResume={() => recorder.resume()} + onStop={() => recorder.stop()} + onTogglePlayback={() => recorder.togglePlayback()} + onReRecord={handleReRecordPress} + onDelete={handleDeletePress} + /> + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: theme.spacing.lg, + gap: theme.spacing.lg, + }, + // Reserves roughly the waveform + controls height so deferring the first + // paint does not shift the surrounding layout when the real UI appears. + deferPlaceholder: { + minHeight: 232, + }, +}); diff --git a/src/app/tabs/drafting/record/components/RecordingControls.test.tsx b/src/app/tabs/drafting/record/components/RecordingControls.test.tsx new file mode 100644 index 0000000..843c545 --- /dev/null +++ b/src/app/tabs/drafting/record/components/RecordingControls.test.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { RecordingControls } from './RecordingControls'; + +const REFERENCE = 'Mark 14:3'; + +describe('RecordingControls', () => { + it('renders the idle record button with a label and disabled play hint', () => { + render( + , + ); + + expect(screen.getByTestId('record-start-button')).toBeTruthy(); + expect(screen.getByTestId('record-start-label')).toHaveTextContent( + 'Record Mark 14:3', + ); + expect(screen.getByTestId('record-play-idle-placeholder')).toBeTruthy(); + }); + + it('shows pause and stop controls with a duration and tip when recording', () => { + render( + , + ); + + expect(screen.getByTestId('record-duration')).toHaveTextContent('01:05:42'); + expect(screen.getByTestId('record-pause-button')).toBeTruthy(); + expect(screen.getByTestId('record-stop-button')).toBeTruthy(); + expect(screen.getByTestId('record-tip')).toHaveTextContent( + 'Tap pause to study the source, stop to finish.', + ); + }); + + it('shows the paused tip instead of the recording tip while paused', () => { + render( + , + ); + + expect(screen.getByTestId('record-tip')).toHaveTextContent( + 'Recording paused — review the source below, then resume.', + ); + }); + + it('shows the resume button while paused', () => { + render( + , + ); + + expect(screen.getByTestId('record-resume-button')).toBeTruthy(); + expect(screen.queryByTestId('record-pause-button')).toBeNull(); + }); + + it('renders review controls with placeholder, play, re-record and delete', () => { + const onDelete = jest.fn(); + + render( + , + ); + + expect( + screen.getByTestId('record-review-record-done-placeholder'), + ).toBeTruthy(); + expect(screen.getByTestId('record-play-button')).toBeTruthy(); + expect(screen.getByTestId('record-rerecord-button')).toHaveTextContent( + 'Re-record', + ); + expect(screen.getByTestId('record-delete-button')).toHaveTextContent( + 'Delete', + ); + expect(screen.queryByTestId('record-tip')).toBeNull(); + + fireEvent.press(screen.getByTestId('record-delete-button')); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it('calls onTogglePlayback when the review play button is pressed', () => { + const onTogglePlayback = jest.fn(); + + render( + , + ); + + expect( + screen.getByTestId('record-play-button').props.accessibilityLabel, + ).toBe('Play draft'); + fireEvent.press(screen.getByTestId('record-play-button')); + expect(onTogglePlayback).toHaveBeenCalledTimes(1); + }); + + it('shows a pause affordance while the draft is playing', () => { + render( + , + ); + + const playButton = screen.getByTestId('record-play-button'); + expect(playButton.props.accessibilityLabel).toBe('Pause draft'); + expect(playButton.props.accessibilityState).toEqual( + expect.objectContaining({ selected: true }), + ); + }); +}); diff --git a/src/app/tabs/drafting/record/components/RecordingControls.tsx b/src/app/tabs/drafting/record/components/RecordingControls.tsx new file mode 100644 index 0000000..3ec2ebb --- /dev/null +++ b/src/app/tabs/drafting/record/components/RecordingControls.tsx @@ -0,0 +1,347 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { + CircleDot, + Pause, + Play, + RefreshCw, + Square, + Trash2, +} from 'lucide-react-native'; +import type { RecorderStatus } from '../../../../../hooks/useRecorder'; +import { theme } from '../../../../../theme'; +import { listIconStrokeWidth } from '../../../../../theme/iconSpecs'; +import { formatDuration } from '../utils/recordTabUtils'; + +interface RecordingControlsProps { + status: RecorderStatus; + reference: string; + elapsedMs: number; + isPlaying: boolean; + onStart: () => void; + onPause: () => void; + onResume: () => void; + onStop: () => void; + onTogglePlayback: () => void; + onReRecord: () => void; + onDelete: () => void; +} + +export function RecordingControls({ + status, + reference, + elapsedMs, + isPlaying, + onStart, + onPause, + onResume, + onStop, + onTogglePlayback, + onReRecord, + onDelete, +}: RecordingControlsProps) { + return ( + + {status === 'idle' && ( + + + + + + Record {reference} + + + + + + )} + + {status === 'recording' && ( + + + {formatDuration(elapsedMs)} + + + + + + + + + + + Tap pause to study the source, stop to finish. + + + )} + + {status === 'paused' && ( + + + {formatDuration(elapsedMs)} + + + + + + + + + + + Recording paused — review the source below, then resume. + + + )} + + {status === 'review' && ( + + + + + + + {isPlaying ? ( + + ) : ( + + )} + + + + + + Re-record + + + + Delete + + + + )} + + ); +} + +const styles = StyleSheet.create({ + controls: { + alignItems: 'center', + gap: theme.spacing.md, + }, + idleGroup: { + alignItems: 'center', + gap: theme.spacing.md, + }, + recordButtonCircle: { + width: 88, + height: 88, + borderRadius: theme.radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.recordAccent, + }, + recordButtonLabel: { + color: theme.colors.foreground, + fontSize: theme.typography.sizes.md, + fontWeight: theme.typography.weights.medium, + textAlign: 'center', + }, + mutedPlaceholderCircle: { + width: 48, + height: 48, + borderRadius: theme.radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.cardBackground, + opacity: 0.6, + }, + captureGroup: { + alignItems: 'center', + gap: theme.spacing.md, + }, + captureButtonsRow: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.lg, + }, + captureTip: { + fontSize: theme.typography.sizes.sm, + color: theme.colors.mutedForeground, + textAlign: 'center', + }, + duration: { + fontSize: 32, + fontWeight: theme.typography.weights.medium, + fontVariant: ['tabular-nums'], + color: theme.colors.mutedForeground, + }, + stopCircleButton: { + width: 56, + height: 56, + borderRadius: theme.radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.cardBackground, + borderWidth: 1, + borderColor: theme.colors.border, + }, + primaryActionCircle: { + width: 72, + height: 72, + borderRadius: theme.radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.recordAccent, + }, + reviewGroup: { + alignItems: 'center', + gap: theme.spacing.lg, + }, + primaryPlayCircle: { + width: 72, + height: 72, + borderRadius: theme.radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.colors.primary, + }, + reviewActions: { + flexDirection: 'row', + gap: theme.spacing.md, + }, + reRecordButton: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.sm, + paddingHorizontal: theme.spacing.lg, + paddingVertical: theme.spacing.md, + borderRadius: theme.radius.md, + backgroundColor: theme.colors.cardBackground, + }, + reRecordLabel: { + color: theme.colors.foreground, + fontSize: theme.typography.sizes.md, + fontWeight: theme.typography.weights.medium, + }, + destructiveButton: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.sm, + paddingHorizontal: theme.spacing.lg, + paddingVertical: theme.spacing.md, + borderRadius: theme.radius.md, + borderWidth: 1, + borderColor: theme.colors.destructive, + backgroundColor: theme.colors.background, + }, + destructiveLabel: { + color: theme.colors.destructive, + fontSize: theme.typography.sizes.md, + fontWeight: theme.typography.weights.medium, + }, +}); diff --git a/src/app/tabs/drafting/record/components/RecordingWaveform.test.tsx b/src/app/tabs/drafting/record/components/RecordingWaveform.test.tsx new file mode 100644 index 0000000..a498629 --- /dev/null +++ b/src/app/tabs/drafting/record/components/RecordingWaveform.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import { RecordingWaveform } from './RecordingWaveform'; + +describe('RecordingWaveform', () => { + it('renders nothing for idle status', () => { + render(); + + expect(screen.queryByTestId('record-waveform-live')).toBeNull(); + expect(screen.queryByTestId('record-waveform-static')).toBeNull(); + }); + + it('renders the live waveform while recording', () => { + render(); + + expect(screen.getByTestId('record-waveform-live')).toBeTruthy(); + expect(screen.queryByTestId('record-waveform-static')).toBeNull(); + }); + + it('renders the live waveform while paused', () => { + render(); + + expect(screen.getByTestId('record-waveform-live')).toBeTruthy(); + }); + + it('renders the static waveform in review', () => { + render(); + + expect(screen.queryByTestId('record-waveform-live')).toBeNull(); + expect(screen.getByTestId('record-waveform-static')).toBeTruthy(); + }); +}); diff --git a/src/app/tabs/drafting/record/components/RecordingWaveform.tsx b/src/app/tabs/drafting/record/components/RecordingWaveform.tsx new file mode 100644 index 0000000..0fe84ac --- /dev/null +++ b/src/app/tabs/drafting/record/components/RecordingWaveform.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import type { RecorderStatus } from '../../../../../hooks/useRecorder'; +import { theme } from '../../../../../theme'; + +const LIVE_WAVEFORM_BARS = 22; + +interface RecordingWaveformProps { + status: RecorderStatus; + elapsedMs: number; +} + +export function RecordingWaveform({ + status, + elapsedMs, +}: RecordingWaveformProps) { + if (status === 'recording' || status === 'paused') { + const active = status === 'recording'; + return ( + + {Array.from({ length: LIVE_WAVEFORM_BARS }).map((_, index) => { + const height = + 14 + ((Math.abs(Math.sin((elapsedMs + index * 40) / 90)) * 48) | 0); + return ( + + ); + })} + + ); + } + + if (status === 'review') { + return ( + + {Array.from({ length: LIVE_WAVEFORM_BARS }).map((_, index) => ( + + ))} + + ); + } + + return null; +} + +const styles = StyleSheet.create({ + waveform: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + height: 72, + width: '50%', + alignSelf: 'center', + }, + waveformBarLive: { + width: 5, + borderRadius: 2.5, + backgroundColor: theme.colors.recordAccent, + }, + waveformBarStatic: { + width: 5, + borderRadius: 2.5, + backgroundColor: theme.colors.primary, + }, +}); diff --git a/src/app/tabs/drafting/record/components/SourceTextPanel.test.tsx b/src/app/tabs/drafting/record/components/SourceTextPanel.test.tsx new file mode 100644 index 0000000..4a8674b --- /dev/null +++ b/src/app/tabs/drafting/record/components/SourceTextPanel.test.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { SourceTextPanel } from './SourceTextPanel'; + +describe('SourceTextPanel', () => { + it('is collapsed by default and toggles open', () => { + render(); + + expect(screen.queryByTestId('record-source-body')).toBeNull(); + fireEvent.press(screen.getByTestId('record-source-toggle')); + expect(screen.getByTestId('record-source-body')).toHaveTextContent( + 'In the beginning', + ); + }); +}); diff --git a/src/app/tabs/drafting/record/components/SourceTextPanel.tsx b/src/app/tabs/drafting/record/components/SourceTextPanel.tsx new file mode 100644 index 0000000..68513f7 --- /dev/null +++ b/src/app/tabs/drafting/record/components/SourceTextPanel.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import { + LayoutAnimation, + Platform, + StyleSheet, + Text, + TouchableOpacity, + UIManager, + View, +} from 'react-native'; +import { ChevronDown, ChevronUp } from 'lucide-react-native'; +import { theme } from '../../../../../theme'; +import { iconSizes, listIconStrokeWidth } from '../../../../../theme/iconSpecs'; + +if (Platform.OS === 'android') { + UIManager.setLayoutAnimationEnabledExperimental?.(true); +} + +interface SourceTextPanelProps { + sourceText: string; +} + +export function SourceTextPanel({ sourceText }: SourceTextPanelProps) { + const [expanded, setExpanded] = useState(false); + + function toggleExpanded() { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setExpanded(prev => !prev); + } + + return ( + <> + + + {expanded ? 'Hide source text' : 'Show source text'} + + {expanded ? ( + + ) : ( + + )} + + {expanded && ( + + {sourceText} + + )} + + ); +} + +const styles = StyleSheet.create({ + accordionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: theme.spacing.md, + backgroundColor: theme.colors.cardBackground, + borderRadius: theme.radius.md, + }, + accordionLabel: { + fontSize: theme.typography.sizes.sm, + fontWeight: theme.typography.weights.medium, + color: theme.colors.foreground, + }, + sourceBody: { + padding: theme.spacing.md, + }, + sourceText: { + fontSize: theme.typography.sizes.md, + lineHeight: theme.typography.lineHeights.normal, + color: theme.colors.foreground, + }, +}); diff --git a/src/app/tabs/drafting/record/components/VerseNav.test.tsx b/src/app/tabs/drafting/record/components/VerseNav.test.tsx new file mode 100644 index 0000000..6c59987 --- /dev/null +++ b/src/app/tabs/drafting/record/components/VerseNav.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import { VerseNav } from './VerseNav'; + +describe('VerseNav', () => { + it('renders the reference', () => { + render( + , + ); + + expect(screen.getByTestId('record-verse-reference')).toHaveTextContent( + 'Mark 14:3', + ); + }); + + it('disables chevrons when prevDisabled or nextDisabled are true', () => { + render( + , + ); + + expect( + screen.getByTestId('record-prev-verse').props.accessibilityState, + ).toEqual(expect.objectContaining({ disabled: true })); + expect( + screen.getByTestId('record-next-verse').props.accessibilityState, + ).toEqual(expect.objectContaining({ disabled: true })); + }); + + it('calls onPrev and onNext when enabled', () => { + const onPrev = jest.fn(); + const onNext = jest.fn(); + + render( + , + ); + + fireEvent.press(screen.getByTestId('record-prev-verse')); + fireEvent.press(screen.getByTestId('record-next-verse')); + + expect(onPrev).toHaveBeenCalledTimes(1); + expect(onNext).toHaveBeenCalledTimes(1); + }); + + it('does not call handlers when disabled', () => { + const onPrev = jest.fn(); + const onNext = jest.fn(); + + render( + , + ); + + fireEvent.press(screen.getByTestId('record-prev-verse')); + fireEvent.press(screen.getByTestId('record-next-verse')); + + expect(onPrev).not.toHaveBeenCalled(); + expect(onNext).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/tabs/drafting/record/components/VerseNav.tsx b/src/app/tabs/drafting/record/components/VerseNav.tsx new file mode 100644 index 0000000..a7592f5 --- /dev/null +++ b/src/app/tabs/drafting/record/components/VerseNav.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { ChevronLeft, ChevronRight } from 'lucide-react-native'; +import { theme } from '../../../../../theme'; +import { iconSizes, listIconStrokeWidth } from '../../../../../theme/iconSpecs'; + +interface VerseNavProps { + reference: string; + prevDisabled: boolean; + nextDisabled: boolean; + onPrev: () => void; + onNext: () => void; +} + +export function VerseNav({ + reference, + prevDisabled, + nextDisabled, + onPrev, + onNext, +}: VerseNavProps) { + return ( + + + + + + {reference} + + + + + + ); +} + +const styles = StyleSheet.create({ + verseNav: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + }, + verseNavButton: { + padding: theme.spacing.sm, + }, + verseReference: { + fontSize: theme.typography.sizes.xl, + fontWeight: theme.typography.weights.bold, + color: theme.colors.foreground, + }, + disabledIcon: { + opacity: 0.35, + }, +}); diff --git a/src/app/tabs/drafting/record/hooks/useRecordTabGuards.test.ts b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.test.ts new file mode 100644 index 0000000..648cb39 --- /dev/null +++ b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.test.ts @@ -0,0 +1,146 @@ +import { Alert } from 'react-native'; +import { act, renderHook } from '@testing-library/react-native'; +import { useRecordTabGuards } from './useRecordTabGuards'; + +const mockNavigation = { + addListener: jest.fn(() => jest.fn()), + dispatch: jest.fn(), +}; + +describe('useRecordTabGuards', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('withPausedGuard shows an alert instead of running the action while paused', () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const action = jest.fn(); + + const { result } = renderHook(() => + useRecordTabGuards({ + status: 'paused', + permission: 'granted', + requestPermission: jest.fn(), + discardPaused: jest.fn().mockResolvedValue(undefined), + navigation: mockNavigation as never, + }), + ); + + result.current.withPausedGuard(action); + + expect(alertSpy).toHaveBeenCalledWith( + 'Recording in progress', + expect.stringContaining('switching verses'), + expect.any(Array), + ); + expect(action).not.toHaveBeenCalled(); + + alertSpy.mockRestore(); + }); + + it('withPausedGuard runs the action immediately when not paused', () => { + const action = jest.fn(); + + const { result } = renderHook(() => + useRecordTabGuards({ + status: 'idle', + permission: 'granted', + requestPermission: jest.fn(), + discardPaused: jest.fn().mockResolvedValue(undefined), + navigation: mockNavigation as never, + }), + ); + + result.current.withPausedGuard(action); + + expect(action).toHaveBeenCalledTimes(1); + }); + + it('withPausedGuard discards the paused take before running the action', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const discardPaused = jest.fn().mockResolvedValue(undefined); + const action = jest.fn(); + + const { result } = renderHook(() => + useRecordTabGuards({ + status: 'paused', + permission: 'granted', + requestPermission: jest.fn(), + discardPaused, + navigation: mockNavigation as never, + }), + ); + + result.current.withPausedGuard(action); + + const buttons = alertSpy.mock.calls[0]![2] as Array<{ + text: string; + onPress?: () => Promise; + }>; + const discardButton = buttons.find(button => button.text === 'Discard'); + await act(async () => { + await discardButton?.onPress?.(); + }); + + expect(discardPaused).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(1); + + alertSpy.mockRestore(); + }); + + it('registers a beforeRemove listener while paused', () => { + renderHook(() => + useRecordTabGuards({ + status: 'paused', + permission: 'granted', + requestPermission: jest.fn(), + discardPaused: jest.fn().mockResolvedValue(undefined), + navigation: mockNavigation as never, + }), + ); + + expect(mockNavigation.addListener).toHaveBeenCalledWith( + 'beforeRemove', + expect.any(Function), + ); + }); + + it('ensureMicPermission returns true when permission is granted', async () => { + const { result } = renderHook(() => + useRecordTabGuards({ + status: 'idle', + permission: 'granted', + requestPermission: jest.fn(), + discardPaused: jest.fn().mockResolvedValue(undefined), + navigation: mockNavigation as never, + }), + ); + + await expect(result.current.ensureMicPermission()).resolves.toBe(true); + }); + + it('ensureMicPermission shows Settings alert when permission is blocked', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const requestPermission = jest.fn(); + + const { result } = renderHook(() => + useRecordTabGuards({ + status: 'idle', + permission: 'blocked', + requestPermission, + discardPaused: jest.fn().mockResolvedValue(undefined), + navigation: mockNavigation as never, + }), + ); + + await expect(result.current.ensureMicPermission()).resolves.toBe(false); + expect(alertSpy).toHaveBeenCalledWith( + 'Microphone access required', + expect.stringContaining('Settings'), + expect.any(Array), + ); + expect(requestPermission).not.toHaveBeenCalled(); + + alertSpy.mockRestore(); + }); +}); diff --git a/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts new file mode 100644 index 0000000..07dfc5d --- /dev/null +++ b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect } from 'react'; +import { Alert, Linking } from 'react-native'; +import type { + PermissionState, + RecorderStatus, +} from '../../../../../hooks/useRecorder'; + +interface RecordTabNavigation { + addListener: ( + event: 'beforeRemove', + callback: (event: { + preventDefault: () => void; + data: { action: unknown }; + }) => void, + ) => () => void; + dispatch: (action: unknown) => void; +} + +interface UseRecordTabGuardsArgs { + status: RecorderStatus; + permission: PermissionState; + requestPermission: () => Promise<{ granted: boolean; canAskAgain: boolean }>; + discardPaused: () => Promise; + navigation: RecordTabNavigation; +} + +function showMicBlockedAlert() { + Alert.alert( + 'Microphone access required', + 'Fluent needs microphone access to record verses. Enable it in Settings.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Go to Settings', + onPress: () => { + Linking.openSettings().catch(() => undefined); + }, + }, + ], + ); +} + +export function useRecordTabGuards({ + status, + permission, + requestPermission, + discardPaused, + navigation, +}: UseRecordTabGuardsArgs) { + const withPausedGuard = useCallback( + (action: () => void) => { + if (status === 'paused') { + Alert.alert( + 'Recording in progress', + 'Resume or discard the paused take before switching verses.', + [ + { text: 'Resume', style: 'cancel' }, + { + text: 'Discard', + style: 'destructive', + onPress: async () => { + await discardPaused(); + action(); + }, + }, + ], + ); + return; + } + action(); + }, + [discardPaused, status], + ); + + useEffect(() => { + if (status !== 'paused') return; + + const beforeRemove = (event: { + preventDefault: () => void; + data: { action: unknown }; + }) => { + event.preventDefault(); + Alert.alert( + 'Recording in progress', + 'You have a paused recording. Resume it or discard the take before leaving.', + [ + { text: 'Resume', style: 'cancel' }, + { + text: 'Discard', + style: 'destructive', + onPress: async () => { + await discardPaused(); + navigation.dispatch(event.data.action); + }, + }, + ], + ); + }; + + const unsubscribe = navigation.addListener('beforeRemove', beforeRemove); + return unsubscribe; + }, [discardPaused, navigation, status]); + + const ensureMicPermission = useCallback(async (): Promise => { + if (permission === 'granted') return true; + if (permission === 'blocked') { + showMicBlockedAlert(); + return false; + } + const { granted, canAskAgain } = await requestPermission(); + if (granted) return true; + if (!canAskAgain) showMicBlockedAlert(); + return false; + }, [permission, requestPermission]); + + return { withPausedGuard, ensureMicPermission }; +} diff --git a/src/hooks/useVerseRecorder.test.ts b/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts similarity index 96% rename from src/hooks/useVerseRecorder.test.ts rename to src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts index c261941..90060f7 100644 --- a/src/hooks/useVerseRecorder.test.ts +++ b/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts @@ -54,19 +54,19 @@ jest.mock('expo-audio', () => ({ setAudioModeAsync: jest.fn().mockResolvedValue(undefined), })); -jest.mock('../db/repository', () => ({ +jest.mock('../../../../../db/repository', () => ({ insertRecording: (input: unknown) => mockInsertRecording(input), deleteRecordingById: (id: string) => mockDeleteRecordingById(id), })); -jest.mock('../db/queries', () => ({ +jest.mock('../../../../../db/queries', () => ({ getLatestRecordingForVerse: (id: number) => mockGetLatestRecordingForVerse(id), })); const MOVED_KEY = 'recordings/u/p0/UNK/c000/v000/test-uuid-1.m4a'; -jest.mock('../services/recordingStorage', () => ({ +jest.mock('../../../../../services/recordingStorage', () => ({ buildRecordingKey: (parts: unknown) => { mockBuildRecordingKey(parts); return MOVED_KEY; @@ -81,14 +81,15 @@ jest.mock('../services/recordingStorage', () => ({ mockDeleteRecordingFile(pathOrKey), })); -jest.mock('../services/storage', () => ({ +jest.mock('../../../../../services/storage', () => ({ getPausedTake: (id: number) => mockGetPausedTake(id), setPausedTake: (marker: unknown) => mockSetPausedTake(marker), clearPausedTake: (id: number) => mockClearPausedTake(id), })); -const RESOLVE_MOCK = jest.requireMock('../services/recordingStorage') - .resolveRecordingUri as jest.Mock; +const RESOLVE_MOCK = jest.requireMock( + '../../../../../services/recordingStorage', +).resolveRecordingUri as jest.Mock; function existingRecording(overrides: Record = {}) { return { diff --git a/src/hooks/useVerseRecorder.ts b/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts similarity index 88% rename from src/hooks/useVerseRecorder.ts rename to src/app/tabs/drafting/record/hooks/useVerseRecorder.ts index bdd9da4..a9c350b 100644 --- a/src/hooks/useVerseRecorder.ts +++ b/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts @@ -1,27 +1,30 @@ import { useMemo } from 'react'; import { randomUUID } from 'expo-crypto'; -import { deleteRecordingById, insertRecording } from '../db/repository'; -import { getLatestRecordingForVerse } from '../db/queries'; +import { + deleteRecordingById, + insertRecording, +} from '../../../../../db/repository'; +import { getLatestRecordingForVerse } from '../../../../../db/queries'; import { clearPausedTake, getPausedTake, setPausedTake, -} from '../services/storage'; +} from '../../../../../services/storage'; import { buildRecordingKey, deleteRecordingFile, extensionFromUri, moveIntoStore, resolveRecordingUri, -} from '../services/recordingStorage'; -import type { Recording } from '../types/db/types'; -import { logger } from '../utils/logger'; +} from '../../../../../services/recordingStorage'; +import type { Recording } from '../../../../../types/db/types'; +import { logger } from '../../../../../utils/logger'; import type { PausedTakeState, RecorderAdapter, UseRecorderApi, -} from './useRecorder'; -import { useRecorder } from './useRecorder'; +} from '../../../../../hooks/useRecorder'; +import { useRecorder } from '../../../../../hooks/useRecorder'; const log = logger.create('useVerseRecorder'); diff --git a/src/app/tabs/drafting/record/utils/recordTabUtils.test.ts b/src/app/tabs/drafting/record/utils/recordTabUtils.test.ts new file mode 100644 index 0000000..59efd5b --- /dev/null +++ b/src/app/tabs/drafting/record/utils/recordTabUtils.test.ts @@ -0,0 +1,21 @@ +import { formatDuration, verseReference } from './recordTabUtils'; + +describe('formatDuration', () => { + it('formats zero as 00:00:00', () => { + expect(formatDuration(0)).toBe('00:00:00'); + }); + + it('formats elapsed milliseconds with centiseconds', () => { + expect(formatDuration(65_420)).toBe('01:05:42'); + }); + + it('clamps negative values to zero', () => { + expect(formatDuration(-1)).toBe('00:00:00'); + }); +}); + +describe('verseReference', () => { + it('joins book, chapter, and verse', () => { + expect(verseReference('Mark', 14, 3)).toBe('Mark 14:3'); + }); +}); diff --git a/src/app/tabs/drafting/record/utils/recordTabUtils.ts b/src/app/tabs/drafting/record/utils/recordTabUtils.ts new file mode 100644 index 0000000..ce6bf5b --- /dev/null +++ b/src/app/tabs/drafting/record/utils/recordTabUtils.ts @@ -0,0 +1,18 @@ +export function formatDuration(ms: number): string { + const safeMs = ms < 0 ? 0 : ms; + const totalCentis = Math.floor(safeMs / 10); + const totalSeconds = Math.floor(totalCentis / 100); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const centis = totalCentis % 100; + const pad = (value: number) => value.toString().padStart(2, '0'); + return `${pad(minutes)}:${pad(seconds)}:${pad(centis)}`; +} + +export function verseReference( + bookName: string, + chapter: number, + verse: number, +): string { + return `${bookName} ${chapter}:${verse}`; +} From e92c11aac8194ca4a2b0cd2b2236535a04785412 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Fri, 3 Jul 2026 00:02:56 -0300 Subject: [PATCH 23/55] [#49]: Harden getPausedTake bibleTextId validation Reject corrupted paused-take markers unless bibleTextId is a finite number matching the KV lookup key. Refs #49 Co-authored-by: Cursor --- src/services/storage.test.ts | 109 +++++++++++++++++++++++++++++++++++ src/services/storage.ts | 2 + 2 files changed, 111 insertions(+) create mode 100644 src/services/storage.test.ts diff --git a/src/services/storage.test.ts b/src/services/storage.test.ts new file mode 100644 index 0000000..405362c --- /dev/null +++ b/src/services/storage.test.ts @@ -0,0 +1,109 @@ +jest.mock('@op-engineering/op-sqlite', () => ({ + Storage: jest.fn().mockImplementation(() => ({ + getItemSync: jest.fn(), + setItemSync: jest.fn(), + removeItemSync: jest.fn(), + })), +})); + +import { Storage } from '@op-engineering/op-sqlite'; +import { clearPausedTake, getPausedTake, setPausedTake } from './storage'; + +const mockGetItemSync = jest.mocked(Storage).mock.results[0]!.value + .getItemSync as jest.Mock; +const mockSetItemSync = jest.mocked(Storage).mock.results[0]!.value + .setItemSync as jest.Mock; +const mockRemoveItemSync = jest.mocked(Storage).mock.results[0]!.value + .removeItemSync as jest.Mock; + +const validMarker = { + bibleTextId: 42, + fileUri: 'file:///docs/partial-take.m4a', + elapsedMs: 4500, + startedAt: '2026-07-01T00:00:00.000Z', +}; + +describe('getPausedTake', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetItemSync.mockReturnValue(null); + }); + + it('returns a valid marker for the lookup key', () => { + mockGetItemSync.mockReturnValue(JSON.stringify(validMarker)); + + expect(getPausedTake(42)).toEqual(validMarker); + expect(mockGetItemSync).toHaveBeenCalledWith('paused_take:42'); + }); + + it('returns null when bibleTextId is missing', () => { + mockGetItemSync.mockReturnValue( + JSON.stringify({ + fileUri: validMarker.fileUri, + elapsedMs: validMarker.elapsedMs, + startedAt: validMarker.startedAt, + }), + ); + + expect(getPausedTake(42)).toBeNull(); + }); + + it('returns null when bibleTextId is not a finite number', () => { + mockGetItemSync.mockReturnValue( + JSON.stringify({ ...validMarker, bibleTextId: '42' }), + ); + expect(getPausedTake(42)).toBeNull(); + + mockGetItemSync.mockReturnValue( + JSON.stringify({ ...validMarker, bibleTextId: Number.NaN }), + ); + expect(getPausedTake(42)).toBeNull(); + }); + + it('returns null when bibleTextId mismatches the lookup key', () => { + mockGetItemSync.mockReturnValue( + JSON.stringify({ ...validMarker, bibleTextId: 99 }), + ); + + expect(getPausedTake(42)).toBeNull(); + }); + + it('returns null when fileUri is missing', () => { + mockGetItemSync.mockReturnValue( + JSON.stringify({ + bibleTextId: validMarker.bibleTextId, + elapsedMs: validMarker.elapsedMs, + startedAt: validMarker.startedAt, + }), + ); + + expect(getPausedTake(42)).toBeNull(); + }); + + it('returns null for invalid JSON', () => { + mockGetItemSync.mockReturnValue('{not json'); + + expect(getPausedTake(42)).toBeNull(); + }); +}); + +describe('setPausedTake / clearPausedTake', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('persists the marker under a bibleTextId-scoped key', () => { + setPausedTake(validMarker); + + expect(mockSetItemSync).toHaveBeenCalledWith( + 'paused_take:42', + JSON.stringify(validMarker), + ); + }); + + it('clears the marker for the given bibleTextId', () => { + clearPausedTake(42); + + expect(mockRemoveItemSync).toHaveBeenCalledWith('paused_take:42'); + }); +}); diff --git a/src/services/storage.ts b/src/services/storage.ts index 35637a5..91364d6 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -197,6 +197,8 @@ export function getPausedTake(bibleTextId: number): PausedTakeMarker | null { try { const parsed = JSON.parse(raw) as PausedTakeMarker; if ( + Number.isFinite(parsed?.bibleTextId) && + parsed.bibleTextId === bibleTextId && typeof parsed?.fileUri === 'string' && typeof parsed?.elapsedMs === 'number' && typeof parsed?.startedAt === 'string' From 6c855854bd83d48b4c8674a611df37b52fe0c088 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Fri, 3 Jul 2026 01:08:22 -0300 Subject: [PATCH 24/55] [#49]: Move RecordingWaveform bar styles out of inline JSX Precompute static bar heights in StyleSheet and split live active/paused styles so react-native/no-inline-styles lint passes cleanly. Refs #49 Co-authored-by: Cursor --- .../record/components/RecordingWaveform.tsx | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/app/tabs/drafting/record/components/RecordingWaveform.tsx b/src/app/tabs/drafting/record/components/RecordingWaveform.tsx index 0fe84ac..a4400f5 100644 --- a/src/app/tabs/drafting/record/components/RecordingWaveform.tsx +++ b/src/app/tabs/drafting/record/components/RecordingWaveform.tsx @@ -1,10 +1,25 @@ import React from 'react'; -import { StyleSheet, View } from 'react-native'; +import { StyleSheet, View, type ViewStyle } from 'react-native'; import type { RecorderStatus } from '../../../../../hooks/useRecorder'; import { theme } from '../../../../../theme'; const LIVE_WAVEFORM_BARS = 22; +function liveBarHeight(height: number): ViewStyle { + return { height }; +} + +const staticBarHeightStyles = StyleSheet.create( + Object.fromEntries( + Array.from({ length: LIVE_WAVEFORM_BARS }, (_, index) => [ + `h${index}`, + { + height: 10 + ((Math.abs(Math.cos(index / 2)) * 28) | 0), + }, + ]), + ) as Record, +); + interface RecordingWaveformProps { status: RecorderStatus; elapsedMs: number; @@ -28,13 +43,15 @@ export function RecordingWaveform({ return ( ); })} @@ -54,10 +71,7 @@ export function RecordingWaveform({ key={index} style={[ styles.waveformBarStatic, - { - height: 10 + ((Math.abs(Math.cos(index / 2)) * 28) | 0), - opacity: 0.85, - }, + staticBarHeightStyles[`h${index}`], ]} /> ))} @@ -82,9 +96,20 @@ const styles = StyleSheet.create({ borderRadius: 2.5, backgroundColor: theme.colors.recordAccent, }, + waveformBarLiveActive: { + opacity: 1, + }, + waveformBarLivePaused: { + height: 14, + width: 5, + borderRadius: 2.5, + backgroundColor: theme.colors.recordAccent, + opacity: 0.5, + }, waveformBarStatic: { width: 5, borderRadius: 2.5, backgroundColor: theme.colors.primary, + opacity: 0.85, }, }); From 1887bdfc3834e7567b7cbc24e9923085c07c3a1f Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Fri, 3 Jul 2026 01:22:08 -0300 Subject: [PATCH 25/55] [#49]: Add expo-asset peer dep and config plugin for expo-audio Install SDK 56-aligned expo-asset and register its config plugin so expo-doctor passes and native prebuild deduplicates expo-asset/constants. Refs #49 Co-authored-by: Cursor --- app.config.ts | 1 + package-lock.json | 726 +++++++++++++++++----------------------------- package.json | 1 + 3 files changed, 269 insertions(+), 459 deletions(-) diff --git a/app.config.ts b/app.config.ts index 42623bc..f09b711 100644 --- a/app.config.ts +++ b/app.config.ts @@ -74,6 +74,7 @@ const config: ExpoConfig = { ], './plugins/withRNScreensFragmentFactory', 'expo-secure-store', + 'expo-asset', [ 'expo-audio', { diff --git a/package-lock.json b/package-lock.json index 7f0c8b9..cbb2db2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@tanstack/react-query": "^5.96.1", "babel-preset-expo": "~56.0.0", "expo": "~56.0.13", + "expo-asset": "~56.0.18", "expo-audio": "~56.0.12", "expo-build-properties": "~56.0.20", "expo-crypto": "~56.0.4", @@ -2365,9 +2366,9 @@ } }, "node_modules/@expo/env": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.0.tgz", - "integrity": "sha512-tSlakVnFqdWlXnw5KCFSC1rQU8W5l0Ko8Pb96wi5XU4lCqc0UeLuZCQFpCeAhZGmd91Lo89mCNmr5tnXzZwtFA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", + "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -2978,9 +2979,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3133,9 +3134,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", - "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", "dev": true, "license": "MIT", "engines": { @@ -3432,9 +3433,9 @@ } }, "node_modules/@nodable/entities": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-1.1.0.tgz", - "integrity": "sha512-bidpxmTBP0pOsxULw6XlxzQpTgrAGLDHGBK/JuWhPDL6ZV0GZ/PmN9CA9do6e+A9lYI6qx6ikJUtJYRxup141g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", "devOptional": true, "funding": [ { @@ -3608,9 +3609,9 @@ } }, "node_modules/@react-native-community/cli-doctor/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "devOptional": true, "license": "ISC", "bin": { @@ -3698,9 +3699,9 @@ } }, "node_modules/@react-native-community/cli-tools/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "devOptional": true, "license": "ISC", "bin": { @@ -3721,9 +3722,9 @@ } }, "node_modules/@react-native-community/cli/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "devOptional": true, "license": "ISC", "bin": { @@ -4051,11 +4052,10 @@ "react": "^19.2.3" } }, - "node_modules/@react-native/jest-preset/node_modules/@react-native/js-polyfills": { + "node_modules/@react-native/js-polyfills": { "version": "0.85.3", "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz", "integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==", - "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" @@ -4603,9 +4603,9 @@ } }, "node_modules/@testing-library/react-native/node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4636,27 +4636,21 @@ } }, "node_modules/@testing-library/react-native/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@testing-library/react-native/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", @@ -5024,9 +5018,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -5320,6 +5314,19 @@ "node": ">= 8" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/appdirsjs": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/appdirsjs/-/appdirsjs-1.2.7.tgz", @@ -5734,9 +5741,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.15.tgz", - "integrity": "sha512-0MqbQoM6nBUbKvgu2xJ4VixZnUTGTq3HB2WwvOikdO4CiPxbQ+wGA25fOoHHSni5iEFW39wy6y1ookTWlq3wVw==", + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.16.tgz", + "integrity": "sha512-HkTZP+nWVGTibw+JCiy2yCXPPXhFUbqG6xdZRSnnAv+tZUdsCdvGnfEl14shS1mZWIY0VSf/rWpkzIEsZKyqhQ==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -5785,7 +5792,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^56.0.18", + "expo-widgets": "^56.0.20", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -5845,7 +5852,6 @@ "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -5870,7 +5876,6 @@ "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.1.tgz", "integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==", "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } @@ -5880,7 +5885,6 @@ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } @@ -5890,7 +5894,6 @@ "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.1.tgz", "integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==", "license": "Apache-2.0", - "optional": true, "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" @@ -5917,7 +5920,6 @@ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-path": "^3.0.0" } @@ -6572,9 +6574,9 @@ } }, "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -6759,43 +6761,6 @@ "node": ">=12" } }, - "node_modules/data-urls/node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -7129,16 +7094,6 @@ "node": ">=12" } }, - "node_modules/domexception/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", @@ -7721,14 +7676,14 @@ "license": "MIT" }, "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", + "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", @@ -8068,14 +8023,13 @@ } }, "node_modules/expo-asset": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.2.tgz", - "integrity": "sha512-58QjXdeklQmauYS7VlxFAk2O/KNsHqpfplHKLcgDQhg3ek26+3LL+lxWwha92JOBcHNUQoqPN47SoEsD2Yjj8A==", + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.18.tgz", + "integrity": "sha512-koV8oOW/U4mPP+sWHhsSxk4GdYrO6s/vZU3/OMeDwiyjeqeNJWfjpVUS94HERzwvcthPiX419X3PNj0p9XKt+g==", "license": "MIT", - "peer": true, "dependencies": { - "@expo/image-utils": "^0.11.0", - "expo-constants": "~57.0.2" + "@expo/image-utils": "^0.10.1", + "expo-constants": "~56.0.19" }, "peerDependencies": { "expo": "*", @@ -8083,55 +8037,6 @@ "react-native": "*" } }, - "node_modules/expo-asset/node_modules/@expo/image-utils": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.0.tgz", - "integrity": "sha512-tXFTMAl79rOiK3q8cCw00CioMxv1XfM31B6B+9IK/yeWKJrwny8sSIJ0IzZWqHy6F23rW5AQVIyT5PJZ5p/hmA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@expo/require-utils": "^57.0.0", - "@expo/spawn-async": "^1.8.0", - "chalk": "^4.0.0", - "getenv": "^2.0.0", - "jimp-compact": "0.16.1", - "parse-png": "^2.1.0", - "semver": "^7.6.0" - } - }, - "node_modules/expo-asset/node_modules/@expo/require-utils": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.0.tgz", - "integrity": "sha512-6PPvmqdAz7ikxJUwg+g/JV1KKQJS1xkkh/r+z4tucvBelrM0vMnENV2QVVn4qDWS0FfSIcwuSYvvx+4od/Sa9w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/expo-asset/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/expo-audio": { "version": "56.0.12", "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-56.0.12.tgz", @@ -8168,9 +8073,9 @@ } }, "node_modules/expo-build-properties/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8180,13 +8085,12 @@ } }, "node_modules/expo-constants": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.2.tgz", - "integrity": "sha512-Bq0lIOB+olAQrYCS/fZi0cu1i8V0lo/YNr9fqMELerOfYYFkg8zTVrVaRjdFdMYBmz3dtym26UEBxV5dyUYZOg==", + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.19.tgz", + "integrity": "sha512-9qUBOnUggmGMng8/J+T5hC/roLWCaG+xh7Lsk7yrqi3ulNOTDX6PzT4MNrNdmhqpD1sUc+KIDxctNxEZ1GxM5Q==", "license": "MIT", - "peer": true, "dependencies": { - "@expo/env": "~2.4.0" + "@expo/env": "~2.3.0" }, "peerDependencies": { "expo": "*", @@ -8563,20 +8467,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/@expo/env": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.0.tgz", - "integrity": "sha512-9HnnIbzwTTdbwSjNLXTk0fPm9ZwMJ7c1/31tsni8HZ8Q62KzYCyspahH+V365vg5J6lr001DzNwBxVWSaYCQLg==", - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "getenv": "^2.0.0" - }, - "engines": { - "node": ">=20.12.0" - } - }, "node_modules/expo/node_modules/@expo/log-box": { "version": "56.0.14", "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-56.0.14.tgz", @@ -8663,73 +8553,6 @@ "node": ">=4" } }, - "node_modules/expo/node_modules/babel-preset-expo": { - "version": "56.0.16", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.16.tgz", - "integrity": "sha512-HkTZP+nWVGTibw+JCiy2yCXPPXhFUbqG6xdZRSnnAv+tZUdsCdvGnfEl14shS1mZWIY0VSf/rWpkzIEsZKyqhQ==", - "license": "MIT", - "dependencies": { - "@babel/generator": "^7.20.5", - "@babel/helper-module-imports": "^7.25.9", - "@babel/plugin-proposal-decorators": "^7.12.9", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-transform-async-generator-functions": "^7.25.4", - "@babel/plugin-transform-async-to-generator": "^7.24.7", - "@babel/plugin-transform-block-scoping": "^7.25.0", - "@babel/plugin-transform-class-properties": "^7.25.4", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-classes": "^7.25.4", - "@babel/plugin-transform-destructuring": "^7.24.8", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-for-of": "^7.24.7", - "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.8", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-react-display-name": "^7.24.7", - "@babel/plugin-transform-react-jsx": "^7.28.6", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/plugin-transform-typescript": "^7.25.2", - "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-plugin-codegen": "0.85.3", - "babel-plugin-react-compiler": "^1.0.0", - "babel-plugin-react-native-web": "~0.21.0", - "babel-plugin-syntax-hermes-parser": "^0.33.3", - "babel-plugin-transform-flow-enums": "^0.0.2", - "debug": "^4.3.4" - }, - "peerDependencies": { - "@babel/runtime": "^7.20.0", - "expo": "*", - "expo-widgets": "^56.0.20", - "react-refresh": ">=0.14.0 <1.0.0" - }, - "peerDependenciesMeta": { - "@babel/runtime": { - "optional": true - }, - "expo": { - "optional": true - }, - "expo-widgets": { - "optional": true - } - } - }, "node_modules/expo/node_modules/cli-cursor": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", @@ -8766,34 +8589,6 @@ "node": ">=0.8.0" } }, - "node_modules/expo/node_modules/expo-asset": { - "version": "56.0.18", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.18.tgz", - "integrity": "sha512-koV8oOW/U4mPP+sWHhsSxk4GdYrO6s/vZU3/OMeDwiyjeqeNJWfjpVUS94HERzwvcthPiX419X3PNj0p9XKt+g==", - "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.10.1", - "expo-constants": "~56.0.19" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo/node_modules/expo-constants": { - "version": "56.0.19", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.19.tgz", - "integrity": "sha512-9qUBOnUggmGMng8/J+T5hC/roLWCaG+xh7Lsk7yrqi3ulNOTDX6PzT4MNrNdmhqpD1sUc+KIDxctNxEZ1GxM5Q==", - "license": "MIT", - "dependencies": { - "@expo/env": "~2.3.0" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-font": { "version": "56.0.7", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.7.tgz", @@ -8921,9 +8716,9 @@ } }, "node_modules/expo/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -9011,15 +8806,6 @@ } } }, - "node_modules/expo/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -9098,9 +8884,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.6.0.tgz", - "integrity": "sha512-5G+uaEBbOm9M4dgMOV3K/rBzfUNGqGqoUTaYJM3hBwM8t71w07gxLQZoTsjkY8FtfjabqgQHEkeIySBDYeBmJw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", + "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", "devOptional": true, "funding": [ { @@ -9110,10 +8896,12 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^1.1.0", - "fast-xml-builder": "^1.1.4", + "@nodable/entities": "^2.2.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^1.0.1", "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" + "strnum": "^2.4.1", + "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -9682,9 +9470,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10102,12 +9890,12 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -10434,6 +10222,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", + "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -10531,9 +10332,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -10792,25 +10593,25 @@ } }, "node_modules/jest-diff": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", - "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.3.0", + "@jest/diff-sequences": "30.4.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-diff/node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dev": true, "license": "MIT", "dependencies": { @@ -10841,27 +10642,21 @@ } }, "node_modules/jest-diff/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-diff/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-docblock": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", @@ -11026,25 +10821,25 @@ } }, "node_modules/jest-matcher-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", - "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.3.0", - "pretty-format": "30.3.0" + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils/node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dev": true, "license": "MIT", "dependencies": { @@ -11075,27 +10870,21 @@ } }, "node_modules/jest-matcher-utils/node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-matcher-utils/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, "node_modules/jest-message-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", @@ -11327,9 +11116,9 @@ } }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -11592,9 +11381,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -11682,43 +11481,6 @@ "node": ">= 6" } }, - "node_modules/jsdom/node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/jsdom/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/jsdom/node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/jsdom/node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -12419,9 +12181,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -13076,9 +12838,9 @@ } }, "node_modules/node-abi/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -13651,9 +13413,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -13861,9 +13623,9 @@ "license": "ISC" }, "node_modules/prebuild-install/node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", "license": "MIT", "dependencies": { "chownr": "^1.1.1", @@ -14194,9 +13956,9 @@ } }, "node_modules/react-devtools-core/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "license": "MIT", "engines": { "node": ">=8.3.0" @@ -14227,9 +13989,25 @@ } }, "node_modules/react-is": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", - "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "license": "MIT" + }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, "license": "MIT" }, "node_modules/react-native": { @@ -14430,9 +14208,9 @@ } }, "node_modules/react-native-bootsplash/node_modules/fast-xml-parser": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz", - "integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==", + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.7.tgz", + "integrity": "sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==", "funding": [ { "type": "github", @@ -14448,9 +14226,9 @@ } }, "node_modules/react-native-bootsplash/node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -14474,9 +14252,9 @@ } }, "node_modules/react-native-bootsplash/node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -14498,9 +14276,9 @@ } }, "node_modules/react-native-bootsplash/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -14642,15 +14420,6 @@ } } }, - "node_modules/react-native/node_modules/@react-native/js-polyfills": { - "version": "0.85.3", - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz", - "integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==", - "license": "MIT", - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - } - }, "node_modules/react-native/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -14661,9 +14430,9 @@ } }, "node_modules/react-native/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -14673,9 +14442,9 @@ } }, "node_modules/react-native/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", "license": "MIT", "engines": { "node": ">=8.3.0" @@ -15300,9 +15069,9 @@ } }, "node_modules/sharp/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -15982,9 +15751,9 @@ } }, "node_modules/strnum": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", - "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", "devOptional": true, "funding": [ { @@ -15992,7 +15761,10 @@ "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } }, "node_modules/structured-headers": { "version": "0.4.1", @@ -16109,9 +15881,9 @@ "license": "MIT" }, "node_modules/tar-fs": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", - "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -16123,12 +15895,13 @@ } }, "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", "license": "MIT", "dependencies": { "b4a": "^1.6.4", + "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } @@ -16138,7 +15911,6 @@ "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", "license": "MIT", - "optional": true, "dependencies": { "streamx": "^2.12.5" } @@ -16257,9 +16029,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -16327,6 +16099,19 @@ "node": ">= 4.0.0" } }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -16793,6 +16578,16 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", @@ -16836,6 +16631,20 @@ "node": ">=12" } }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-url-minimum": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz", @@ -17001,9 +16810,9 @@ } }, "node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.4.tgz", + "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -17139,9 +16948,9 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -17178,10 +16987,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index e8e7174..30f33d0 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@tanstack/react-query": "^5.96.1", "babel-preset-expo": "~56.0.0", "expo": "~56.0.13", + "expo-asset": "~56.0.18", "expo-audio": "~56.0.12", "expo-build-properties": "~56.0.20", "expo-crypto": "~56.0.4", From 123a11d66d7f9b4e4213406ee39a590d2c723a40 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Fri, 3 Jul 2026 01:25:53 -0300 Subject: [PATCH 26/55] [#49]: Clear bibleTextId before verse lookup resolves Reset bibleTextId to null synchronously when chapterData or selectedVerse changes so RecordTab cannot act on a stale id while getBibleTextId is in flight. Refs #49 Co-authored-by: Cursor --- src/app/tabs/drafting/DraftingPage.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/app/tabs/drafting/DraftingPage.tsx b/src/app/tabs/drafting/DraftingPage.tsx index abaa543..facefe7 100644 --- a/src/app/tabs/drafting/DraftingPage.tsx +++ b/src/app/tabs/drafting/DraftingPage.tsx @@ -87,16 +87,19 @@ export default function DraftingPage() { useEffect(() => { let cancelled = false; + setBibleTextId(null); + + if (!chapterData) { + return; + } + + const { bibleId, bookId, chapterNumber } = chapterData; async function resolveId() { - if (!chapterData) { - setBibleTextId(null); - return; - } const id = await getBibleTextId( - chapterData.bibleId, - chapterData.bookId, - chapterData.chapterNumber, + bibleId, + bookId, + chapterNumber, selectedVerse, ); if (!cancelled) setBibleTextId(id); From b9d5751007c4508f9ec17211669b9c731285f6c9 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Fri, 3 Jul 2026 01:27:41 -0300 Subject: [PATCH 27/55] [#49]: Guard discard-then-continue handlers in record tab guards Wrap discardPaused in try/catch so a failed discard does not leave verse switches or navigation blocked without user feedback. Refs #49 Co-authored-by: Cursor --- .../record/hooks/useRecordTabGuards.test.ts | 94 +++++++++++++++++++ .../record/hooks/useRecordTabGuards.ts | 32 +++++-- 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/src/app/tabs/drafting/record/hooks/useRecordTabGuards.test.ts b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.test.ts index 648cb39..e1ffa85 100644 --- a/src/app/tabs/drafting/record/hooks/useRecordTabGuards.test.ts +++ b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.test.ts @@ -88,6 +88,100 @@ describe('useRecordTabGuards', () => { alertSpy.mockRestore(); }); + it('withPausedGuard does not run the action when discard fails', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const discardPaused = jest + .fn() + .mockRejectedValue(new Error('discard failed')); + const action = jest.fn(); + + const { result } = renderHook(() => + useRecordTabGuards({ + status: 'paused', + permission: 'granted', + requestPermission: jest.fn(), + discardPaused, + navigation: mockNavigation as never, + }), + ); + + result.current.withPausedGuard(action); + + const buttons = alertSpy.mock.calls[0]![2] as Array<{ + text: string; + onPress?: () => void | Promise; + }>; + const discardButton = buttons.find(button => button.text === 'Discard'); + await act(async () => { + await discardButton?.onPress?.(); + }); + + expect(discardPaused).toHaveBeenCalledTimes(1); + expect(action).not.toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalledWith( + 'Could not discard recording', + expect.stringContaining('paused take'), + ); + + alertSpy.mockRestore(); + }); + + it('beforeRemove does not dispatch navigation when discard fails', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const discardPaused = jest + .fn() + .mockRejectedValue(new Error('discard failed')); + const dispatch = jest.fn(); + const navigationAction = { type: 'GO_BACK' }; + let beforeRemoveHandler: + | ((event: { + preventDefault: () => void; + data: { action: unknown }; + }) => void) + | undefined; + + const navigation = { + addListener: jest.fn((event, handler) => { + if (event === 'beforeRemove') beforeRemoveHandler = handler; + return jest.fn(); + }), + dispatch, + }; + + renderHook(() => + useRecordTabGuards({ + status: 'paused', + permission: 'granted', + requestPermission: jest.fn(), + discardPaused, + navigation: navigation as never, + }), + ); + + beforeRemoveHandler?.({ + preventDefault: jest.fn(), + data: { action: navigationAction }, + }); + + const buttons = alertSpy.mock.calls[0]![2] as Array<{ + text: string; + onPress?: () => void | Promise; + }>; + const discardButton = buttons.find(button => button.text === 'Discard'); + await act(async () => { + await discardButton?.onPress?.(); + }); + + expect(discardPaused).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalledWith( + 'Could not discard recording', + expect.stringContaining('paused take'), + ); + + alertSpy.mockRestore(); + }); + it('registers a beforeRemove listener while paused', () => { renderHook(() => useRecordTabGuards({ diff --git a/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts index 07dfc5d..37e078f 100644 --- a/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts +++ b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts @@ -4,6 +4,9 @@ import type { PermissionState, RecorderStatus, } from '../../../../../hooks/useRecorder'; +import { logger } from '../../../../../utils/logger'; + +const log = logger.create('useRecordTabGuards'); interface RecordTabNavigation { addListener: ( @@ -24,6 +27,22 @@ interface UseRecordTabGuardsArgs { navigation: RecordTabNavigation; } +async function discardPausedThen( + discardPaused: () => Promise, + onSuccess: () => void, +) { + try { + await discardPaused(); + onSuccess(); + } catch (error) { + log.warn('Failed to discard paused take', { error }); + Alert.alert( + 'Could not discard recording', + 'The paused take could not be removed. Try resuming it, or restart the app if the problem continues.', + ); + } +} + function showMicBlockedAlert() { Alert.alert( 'Microphone access required', @@ -58,10 +77,7 @@ export function useRecordTabGuards({ { text: 'Discard', style: 'destructive', - onPress: async () => { - await discardPaused(); - action(); - }, + onPress: () => discardPausedThen(discardPaused, action), }, ], ); @@ -88,10 +104,10 @@ export function useRecordTabGuards({ { text: 'Discard', style: 'destructive', - onPress: async () => { - await discardPaused(); - navigation.dispatch(event.data.action); - }, + onPress: () => + discardPausedThen(discardPaused, () => + navigation.dispatch(event.data.action), + ), }, ], ); From ec1e31ad5e4498966d795c2d008f6d907ba27408 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Fri, 3 Jul 2026 01:30:20 -0300 Subject: [PATCH 28/55] [#49]: Guard navigation leave while actively recording Extend the Record tab beforeRemove listener to cover recording state, not just paused, so users must discard the take before leaving. Refs #49 Co-authored-by: Cursor --- .../record/hooks/useRecordTabGuards.test.ts | 75 +++++++++++++++++-- .../record/hooks/useRecordTabGuards.ts | 9 ++- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/app/tabs/drafting/record/hooks/useRecordTabGuards.test.ts b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.test.ts index e1ffa85..ffb16fd 100644 --- a/src/app/tabs/drafting/record/hooks/useRecordTabGuards.test.ts +++ b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.test.ts @@ -182,21 +182,82 @@ describe('useRecordTabGuards', () => { alertSpy.mockRestore(); }); - it('registers a beforeRemove listener while paused', () => { + it.each(['paused', 'recording'] as const)( + 'registers a beforeRemove listener while %s', + status => { + renderHook(() => + useRecordTabGuards({ + status, + permission: 'granted', + requestPermission: jest.fn(), + discardPaused: jest.fn().mockResolvedValue(undefined), + navigation: mockNavigation as never, + }), + ); + + expect(mockNavigation.addListener).toHaveBeenCalledWith( + 'beforeRemove', + expect.any(Function), + ); + }, + ); + + it('beforeRemove prompts to discard an active recording before leaving', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const discardPaused = jest.fn().mockResolvedValue(undefined); + const dispatch = jest.fn(); + const navigationAction = { type: 'GO_BACK' }; + let beforeRemoveHandler: + | ((event: { + preventDefault: () => void; + data: { action: unknown }; + }) => void) + | undefined; + + const navigation = { + addListener: jest.fn((event, handler) => { + if (event === 'beforeRemove') beforeRemoveHandler = handler; + return jest.fn(); + }), + dispatch, + }; + renderHook(() => useRecordTabGuards({ - status: 'paused', + status: 'recording', permission: 'granted', requestPermission: jest.fn(), - discardPaused: jest.fn().mockResolvedValue(undefined), - navigation: mockNavigation as never, + discardPaused, + navigation: navigation as never, }), ); - expect(mockNavigation.addListener).toHaveBeenCalledWith( - 'beforeRemove', - expect.any(Function), + const preventDefault = jest.fn(); + beforeRemoveHandler?.({ + preventDefault, + data: { action: navigationAction }, + }); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(alertSpy).toHaveBeenCalledWith( + 'Recording in progress', + expect.stringContaining('actively recording'), + expect.any(Array), ); + + const buttons = alertSpy.mock.calls[0]![2] as Array<{ + text: string; + onPress?: () => void | Promise; + }>; + const discardButton = buttons.find(button => button.text === 'Discard'); + await act(async () => { + await discardButton?.onPress?.(); + }); + + expect(discardPaused).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith(navigationAction); + + alertSpy.mockRestore(); }); it('ensureMicPermission returns true when permission is granted', async () => { diff --git a/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts index 37e078f..de2fe39 100644 --- a/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts +++ b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts @@ -89,8 +89,9 @@ export function useRecordTabGuards({ ); useEffect(() => { - if (status !== 'paused') return; + if (status !== 'paused' && status !== 'recording') return; + const isRecording = status === 'recording'; const beforeRemove = (event: { preventDefault: () => void; data: { action: unknown }; @@ -98,9 +99,11 @@ export function useRecordTabGuards({ event.preventDefault(); Alert.alert( 'Recording in progress', - 'You have a paused recording. Resume it or discard the take before leaving.', + isRecording + ? 'You are actively recording. Discard the take before leaving.' + : 'You have a paused recording. Resume it or discard the take before leaving.', [ - { text: 'Resume', style: 'cancel' }, + { text: isRecording ? 'Keep recording' : 'Resume', style: 'cancel' }, { text: 'Discard', style: 'destructive', From 82eab1afaab8f7c2410ac59ed25fcd7b4ae6c588 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Fri, 3 Jul 2026 01:31:38 -0300 Subject: [PATCH 29/55] [#49]: Unlink durable file when insertRecording fails after move If moveIntoStore succeeds but the DB insert rejects, remove the moved file before returning null so commit failures stay recoverable without leaving orphaned audio on disk. Refs #49 Co-authored-by: Cursor --- .../record/hooks/useVerseRecorder.test.ts | 27 +++++++++++++++++++ .../drafting/record/hooks/useVerseRecorder.ts | 27 ++++++++++++------- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts b/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts index 90060f7..e9a3226 100644 --- a/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts +++ b/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts @@ -233,6 +233,33 @@ describe('useVerseRecorder', () => { } }); + it('removes the durable file when insertRecording fails after moveIntoStore', async () => { + jest.useFakeTimers(); + try { + mockInsertRecording.mockRejectedValueOnce(new Error('db write failed')); + const { result } = renderHook(() => + useVerseRecorder({ bibleTextId: 42 }), + ); + await waitReady(result); + + jest.setSystemTime(new Date('2026-07-01T00:00:00.000Z')); + await act(async () => { + await result.current.start(); + }); + + jest.setSystemTime(new Date('2026-07-01T00:00:02.000Z')); + await act(async () => { + await result.current.stop(); + }); + + expect(mockDeleteRecordingFile).toHaveBeenCalledWith(MOVED_KEY); + expect(result.current.status).toBe('idle'); + expect(result.current.currentRecording).toBeNull(); + } finally { + jest.useRealTimers(); + } + }); + it('deletes the current take via the repository', async () => { mockGetLatestRecordingForVerse.mockResolvedValueOnce(existingRecording()); const { result } = renderHook(() => useVerseRecorder({ bibleTextId: 42 })); diff --git a/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts b/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts index a9c350b..dd050f9 100644 --- a/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts +++ b/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts @@ -108,15 +108,24 @@ export function useVerseRecorder({ return null; } - return insertRecording({ - id: recordingId, - bibleTextId, - userId: userId ?? null, - chapterAssignmentId: chapterAssignmentId ?? null, - localFilePath: moved.key, - durationMs, - fileSizeBytes: moved.sizeBytes, - }); + try { + return await insertRecording({ + id: recordingId, + bibleTextId, + userId: userId ?? null, + chapterAssignmentId: chapterAssignmentId ?? null, + localFilePath: moved.key, + durationMs, + fileSizeBytes: moved.sizeBytes, + }); + } catch (error) { + log.error( + 'Failed to insert recording after move; removing durable file', + { bibleTextId, localFilePath: moved.key, error }, + ); + deleteRecordingFile(moved.key); + return null; + } }, deleteCommitted: take => deleteRecordingById(take.id), From 718ea65d44feed26aa32736117d8dd52ab13c68c Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Fri, 3 Jul 2026 01:33:43 -0300 Subject: [PATCH 30/55] [#49]: Handle pause, resume, and stop recorder failures in RecordTab Await recorder pause/resume/stop and surface user-visible alerts when native recording operations reject, matching discardPaused error handling. Refs #49 Co-authored-by: Cursor --- src/app/tabs/drafting/record/RecordTab.tsx | 45 ++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/src/app/tabs/drafting/record/RecordTab.tsx b/src/app/tabs/drafting/record/RecordTab.tsx index 90f1796..97f566f 100644 --- a/src/app/tabs/drafting/record/RecordTab.tsx +++ b/src/app/tabs/drafting/record/RecordTab.tsx @@ -7,10 +7,13 @@ import { RecordingControls } from './components/RecordingControls'; import { RecordingWaveform } from './components/RecordingWaveform'; import { SourceTextPanel } from './components/SourceTextPanel'; import { VerseNav } from './components/VerseNav'; +import { logger } from '../../../../utils/logger'; import { useRecordTabGuards } from './hooks/useRecordTabGuards'; import { useVerseRecorder } from './hooks/useVerseRecorder'; import { verseReference } from './utils/recordTabUtils'; +const log = logger.create('RecordTab'); + // Hold the record/review UI back for a short beat on mount and verse switches. // The recording state is loaded from the local DB (a few ms), so deferring the // first paint lets it resolve first — the UI settles straight into the correct @@ -122,6 +125,42 @@ export function RecordTab({ await recorder.reRecord(); } + async function handlePausePress() { + try { + await recorder.pause(); + } catch (error) { + log.warn('Failed to pause recording', { error }); + Alert.alert( + 'Could not pause recording', + 'Something went wrong while pausing. Try again, or restart the app if the problem continues.', + ); + } + } + + async function handleResumePress() { + try { + await recorder.resume(); + } catch (error) { + log.warn('Failed to resume recording', { error }); + Alert.alert( + 'Could not resume recording', + 'Something went wrong while resuming. Try again, or restart the app if the problem continues.', + ); + } + } + + async function handleStopPress() { + try { + await recorder.stop(); + } catch (error) { + log.warn('Failed to stop recording', { error }); + Alert.alert( + 'Could not save recording', + 'Your take could not be saved. Try stopping again, or restart the app if the problem continues.', + ); + } + } + function handleDeletePress() { Alert.alert('Delete draft?', 'This cannot be undone.', [ { text: 'Cancel', style: 'cancel' }, @@ -159,9 +198,9 @@ export function RecordTab({ elapsedMs={recorder.elapsedMs} isPlaying={recorder.isPlaying} onStart={handleStartPress} - onPause={() => recorder.pause()} - onResume={() => recorder.resume()} - onStop={() => recorder.stop()} + onPause={handlePausePress} + onResume={handleResumePress} + onStop={handleStopPress} onTogglePlayback={() => recorder.togglePlayback()} onReRecord={handleReRecordPress} onDelete={handleDeletePress} From 64efd0ec085c5333b7fbbdd2ab18798c3ea14782 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Fri, 3 Jul 2026 01:37:21 -0300 Subject: [PATCH 31/55] [#49]: Block resume on rehydrated paused takes without live recorder Track an in-memory live session token when recording starts and gate resume/stop on it. Rehydrated paused markers steer the UI toward discard instead of resume, since the native AudioRecorder session is gone. Refs #49 Co-authored-by: Cursor --- .../tabs/drafting/record/RecordTab.test.tsx | 1 + src/app/tabs/drafting/record/RecordTab.tsx | 14 ++++ .../components/RecordingControls.test.tsx | 31 ++++++- .../record/components/RecordingControls.tsx | 80 ++++++++++++------- .../record/hooks/useVerseRecorder.test.ts | 3 + src/hooks/useRecorder.test.ts | 25 ++++++ src/hooks/useRecorder.ts | 34 +++++++- src/services/storage.ts | 1 + 8 files changed, 158 insertions(+), 31 deletions(-) diff --git a/src/app/tabs/drafting/record/RecordTab.test.tsx b/src/app/tabs/drafting/record/RecordTab.test.tsx index 3e4daba..5fb5860 100644 --- a/src/app/tabs/drafting/record/RecordTab.test.tsx +++ b/src/app/tabs/drafting/record/RecordTab.test.tsx @@ -37,6 +37,7 @@ function baseRecorderState() { permission: 'granted' as const, currentRecording: null, isReady: true, + canResume: true, isPlaying: false, requestPermission: jest .fn() diff --git a/src/app/tabs/drafting/record/RecordTab.tsx b/src/app/tabs/drafting/record/RecordTab.tsx index 97f566f..21fefa2 100644 --- a/src/app/tabs/drafting/record/RecordTab.tsx +++ b/src/app/tabs/drafting/record/RecordTab.tsx @@ -161,6 +161,18 @@ export function RecordTab({ } } + async function handleDiscardPress() { + try { + await recorder.discardPaused(); + } catch (error) { + log.warn('Failed to discard recovered take', { error }); + Alert.alert( + 'Could not discard take', + 'Something went wrong while removing the recovered take. Try again, or restart the app if the problem continues.', + ); + } + } + function handleDeletePress() { Alert.alert('Delete draft?', 'This cannot be undone.', [ { text: 'Cancel', style: 'cancel' }, @@ -197,10 +209,12 @@ export function RecordTab({ reference={currentReference} elapsedMs={recorder.elapsedMs} isPlaying={recorder.isPlaying} + canResume={recorder.canResume} onStart={handleStartPress} onPause={handlePausePress} onResume={handleResumePress} onStop={handleStopPress} + onDiscard={handleDiscardPress} onTogglePlayback={() => recorder.togglePlayback()} onReRecord={handleReRecordPress} onDelete={handleDeletePress} diff --git a/src/app/tabs/drafting/record/components/RecordingControls.test.tsx b/src/app/tabs/drafting/record/components/RecordingControls.test.tsx index 843c545..95d210c 100644 --- a/src/app/tabs/drafting/record/components/RecordingControls.test.tsx +++ b/src/app/tabs/drafting/record/components/RecordingControls.test.tsx @@ -76,13 +76,14 @@ describe('RecordingControls', () => { ); }); - it('shows the resume button while paused', () => { + it('shows the resume button while paused with a live session', () => { render( { expect(screen.getByTestId('record-resume-button')).toBeTruthy(); expect(screen.queryByTestId('record-pause-button')).toBeNull(); + expect(screen.queryByTestId('record-discard-button')).toBeNull(); + }); + + it('shows discard instead of resume for a rehydrated paused take', () => { + render( + , + ); + + expect(screen.getByTestId('record-discard-button')).toBeTruthy(); + expect(screen.queryByTestId('record-resume-button')).toBeNull(); + expect(screen.queryByTestId('record-stop-button')).toBeNull(); + expect(screen.getByTestId('record-tip')).toHaveTextContent( + 'A paused take was recovered from a previous session. Discard it to start fresh.', + ); }); it('renders review controls with placeholder, play, re-record and delete', () => { diff --git a/src/app/tabs/drafting/record/components/RecordingControls.tsx b/src/app/tabs/drafting/record/components/RecordingControls.tsx index 3ec2ebb..6b5188e 100644 --- a/src/app/tabs/drafting/record/components/RecordingControls.tsx +++ b/src/app/tabs/drafting/record/components/RecordingControls.tsx @@ -18,10 +18,13 @@ interface RecordingControlsProps { reference: string; elapsedMs: number; isPlaying: boolean; + /** False when a paused take was rehydrated without a live native recorder session. */ + canResume?: boolean; onStart: () => void; onPause: () => void; onResume: () => void; onStop: () => void; + onDiscard?: () => void; onTogglePlayback: () => void; onReRecord: () => void; onDelete: () => void; @@ -32,10 +35,12 @@ export function RecordingControls({ reference, elapsedMs, isPlaying, + canResume = true, onStart, onPause, onResume, onStop, + onDiscard, onTogglePlayback, onReRecord, onDelete, @@ -121,35 +126,56 @@ export function RecordingControls({ {formatDuration(elapsedMs)} - - - - - - + {canResume ? ( + <> + + + + + + + + ) : ( + + + Discard take + + )} - Recording paused — review the source below, then resume. + {canResume + ? 'Recording paused — review the source below, then resume.' + : 'A paused take was recovered from a previous session. Discard it to start fresh.'} )} diff --git a/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts b/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts index e9a3226..21bc850 100644 --- a/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts +++ b/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts @@ -173,6 +173,7 @@ describe('useVerseRecorder', () => { bibleTextId: 42, fileUri: 'file:///tmp/take-1.m4a', elapsedMs: 2500, + sessionToken: expect.any(String), }), ); } finally { @@ -280,11 +281,13 @@ describe('useVerseRecorder', () => { fileUri: 'file:///docs/partial-take.m4a', elapsedMs: 4500, startedAt: '2026-07-01T00:00:00.000Z', + sessionToken: 'stale-token', }); const { result } = renderHook(() => useVerseRecorder({ bibleTextId: 42 })); await waitReady(result); expect(result.current.status).toBe('paused'); + expect(result.current.canResume).toBe(false); await act(async () => { await result.current.discardPaused(); diff --git a/src/hooks/useRecorder.test.ts b/src/hooks/useRecorder.test.ts index 27582ff..c7c4ddc 100644 --- a/src/hooks/useRecorder.test.ts +++ b/src/hooks/useRecorder.test.ts @@ -132,12 +132,34 @@ describe('useRecorder', () => { fileUri: '/tmp/paused.m4a', elapsedMs: 4500, startedAt: '2026-07-01T00:00:00.000Z', + sessionToken: 'stale-token', }), }); const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); expect(result.current.status).toBe('paused'); expect(result.current.elapsedMs).toBe(4500); + expect(result.current.canResume).toBe(false); + }); + + it('does not resume a rehydrated paused take without a live native session', async () => { + const adapter = makeAdapter({ + loadPaused: jest.fn().mockReturnValue({ + fileUri: '/tmp/paused.m4a', + elapsedMs: 4500, + startedAt: '2026-07-01T00:00:00.000Z', + sessionToken: 'stale-token', + }), + }); + const { result } = renderHook(() => useRecorder(adapter)); + await waitReady(result); + + await act(async () => { + await result.current.resume(); + }); + + expect(mockRecorder.record).not.toHaveBeenCalled(); + expect(result.current.status).toBe('paused'); }); it('transitions idle -> recording on start', async () => { @@ -178,10 +200,12 @@ describe('useRecorder', () => { expect.objectContaining({ fileUri: 'file:///tmp/take-1.m4a', elapsedMs: 2500, + sessionToken: expect.any(String), }), ); expect(result.current.status).toBe('paused'); expect(result.current.elapsedMs).toBe(2500); + expect(result.current.canResume).toBe(true); // Long pause window: elapsed stays put; resume starts a new active segment. jest.setSystemTime(new Date('2026-07-01T00:00:12.500Z')); @@ -443,6 +467,7 @@ describe('useRecorder', () => { fileUri: 'file:///docs/partial-take.m4a', elapsedMs: 4500, startedAt: '2026-07-01T00:00:00.000Z', + sessionToken: 'stale-token', }), }); diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts index 7afb066..cfd4f4a 100644 --- a/src/hooks/useRecorder.ts +++ b/src/hooks/useRecorder.ts @@ -37,6 +37,8 @@ export interface PausedTakeState { fileUri: string; elapsedMs: number; startedAt: string; + /** Present on markers written during a live session; absent on legacy markers. */ + sessionToken?: string; } /** @@ -82,6 +84,8 @@ export interface UseRecorderApi { permission: PermissionState; currentRecording: T | null; isReady: boolean; + /** False when a paused take was rehydrated from storage (no live native session). */ + canResume: boolean; isPlaying: boolean; requestPermission: () => Promise; @@ -100,6 +104,10 @@ export interface UseRecorderApi { // (`MM:SS:HH`) without hammering the React state. const TICK_INTERVAL_MS = 50; +function createLiveSessionToken(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; +} + interface PermissionLike { granted: boolean; canAskAgain?: boolean; @@ -140,6 +148,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { const [elapsedMs, setElapsedMs] = useState(0); const [currentRecording, setCurrentRecording] = useState(null); const [isReady, setIsReady] = useState(false); + const [canResume, setCanResume] = useState(false); // The adapter is read via a ref inside callbacks/effects so they always see // the latest injected closures without being re-created (and re-subscribing) @@ -168,6 +177,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { // segment's contribution is measured by wall clock via `runningSinceRef`. const baseElapsedRef = useRef(0); const runningSinceRef = useRef(null); + const liveSessionTokenRef = useRef(null); const sessionKeyRef = useRef(sessionKey); sessionKeyRef.current = sessionKey; @@ -194,10 +204,12 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { async function load() { setIsReady(false); + setCanResume(false); setElapsedMs(0); baseElapsedRef.current = 0; runningSinceRef.current = null; startedAtRef.current = null; + liveSessionTokenRef.current = null; clearTick(); if (sessionKey === null) { @@ -217,7 +229,10 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { setCurrentRecording(latest); if (paused) { - // Surface the marker so the UI can prompt Resume/Discard on next mount. + // Restored markers have no live native recorder — resume is blocked; + // the UI should steer the user toward discardPaused instead. + liveSessionTokenRef.current = null; + setCanResume(false); setStatus('paused'); setElapsedMs(paused.elapsedMs); baseElapsedRef.current = paused.elapsedMs; @@ -273,13 +288,16 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { const persistPausedMarker = useCallback( (rec: AudioRecorder, elapsed: number) => { const currentSessionKey = sessionKeyRef.current; - if (currentSessionKey === null || !rec.uri) return; + const sessionToken = liveSessionTokenRef.current; + if (currentSessionKey === null || !rec.uri || sessionToken === null) + return; const startedAt = startedAtRef.current ?? new Date().toISOString(); startedAtRef.current = startedAt; adapterRef.current.persistPaused({ fileUri: rec.uri, elapsedMs: elapsed, startedAt, + sessionToken, }); }, [], @@ -298,6 +316,8 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { const startRecordingSession = useCallback(async () => { await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true }); await recorder.prepareToRecordAsync(); + liveSessionTokenRef.current = createLiveSessionToken(); + setCanResume(false); baseElapsedRef.current = 0; runningSinceRef.current = Date.now(); startedAtRef.current = new Date().toISOString(); @@ -332,6 +352,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { runningSinceRef.current = null; setElapsedMs(finalElapsed); setStatus('paused'); + setCanResume(liveSessionTokenRef.current !== null); persistPausedMarker(recorder, finalElapsed); }, [clearTick, persistPausedMarker, recorder, status]); @@ -340,10 +361,11 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { }, [pauseInternal]); const resume = useCallback(async () => { - if (status !== 'paused') return; + if (status !== 'paused' || liveSessionTokenRef.current === null) return; recorder.record(); runningSinceRef.current = Date.now(); setStatus('recording'); + setCanResume(false); startTicking(); }, [recorder, startTicking, status]); @@ -392,6 +414,8 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { baseElapsedRef.current = 0; runningSinceRef.current = null; startedAtRef.current = null; + liveSessionTokenRef.current = null; + setCanResume(false); setElapsedMs(duration); setCurrentRecording(committed); setStatus('review'); @@ -399,6 +423,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { const stop = useCallback(async () => { if (status !== 'recording' && status !== 'paused') return; + if (status === 'paused' && liveSessionTokenRef.current === null) return; await commitRecording(); }, [commitRecording, status]); @@ -446,6 +471,8 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { baseElapsedRef.current = 0; runningSinceRef.current = null; startedAtRef.current = null; + liveSessionTokenRef.current = null; + setCanResume(false); setElapsedMs(0); setStatus(currentRecording ? 'review' : 'idle'); }, [clearTick, currentRecording, recorder]); @@ -461,6 +488,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { permission, currentRecording, isReady, + canResume, isPlaying, requestPermission, start, diff --git a/src/services/storage.ts b/src/services/storage.ts index 91364d6..1b6e72b 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -185,6 +185,7 @@ export interface PausedTakeMarker { fileUri: string; elapsedMs: number; startedAt: string; + sessionToken?: string; } function pausedTakeKey(bibleTextId: number): string { From 5126f7f1a43278f3da228411b114c7fdf9e8f1d5 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Fri, 3 Jul 2026 01:42:17 -0300 Subject: [PATCH 32/55] [#49]: Harden useRecorder stop and delete error handling Wrap recorder.stop() in commitRecording with try/catch/finally so a native stop failure clears the tick interval and skips onCommit. Catch deleteCommitted failures in deleteCurrent and leave the take in review. Refs #49 Co-authored-by: Cursor --- src/hooks/useRecorder.test.ts | 57 +++++++++++++++++++++++++++++++++++ src/hooks/useRecorder.ts | 25 +++++++++++++-- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/hooks/useRecorder.test.ts b/src/hooks/useRecorder.test.ts index c7c4ddc..070bfbf 100644 --- a/src/hooks/useRecorder.test.ts +++ b/src/hooks/useRecorder.test.ts @@ -280,6 +280,44 @@ describe('useRecorder', () => { expect(result.current.currentRecording).toBeNull(); }); + it('reverts without committing when recorder.stop throws', async () => { + mockRecorder.stop.mockRejectedValueOnce(new Error('stop failed')); + const adapter = makeAdapter(); + const { result } = renderHook(() => useRecorder(adapter)); + await waitReady(result); + + await act(async () => { + await result.current.start(); + }); + await act(async () => { + await result.current.stop(); + }); + + expect(adapter.onCommit).not.toHaveBeenCalled(); + expect(result.current.status).toBe('idle'); + }); + + it('returns to review when recorder.stop throws during re-record', async () => { + const existing: FakeTake = { id: 'existing', uri: '/tmp/existing.m4a' }; + mockRecorder.stop.mockRejectedValueOnce(new Error('stop failed')); + const adapter = makeAdapter({ + loadInitial: jest.fn().mockResolvedValue(existing), + }); + const { result } = renderHook(() => useRecorder(adapter)); + await waitReady(result); + + await act(async () => { + await result.current.reRecord(); + }); + await act(async () => { + await result.current.stop(); + }); + + expect(adapter.onCommit).not.toHaveBeenCalled(); + expect(result.current.status).toBe('review'); + expect(result.current.currentRecording?.id).toBe('existing'); + }); + it('delete removes the current take and returns to idle', async () => { const existing: FakeTake = { id: 'existing', uri: '/tmp/existing.m4a' }; const adapter = makeAdapter({ @@ -298,6 +336,25 @@ describe('useRecorder', () => { expect(result.current.currentRecording).toBeNull(); }); + it('keeps the current take in review when deleteCommitted throws', async () => { + const existing: FakeTake = { id: 'existing', uri: '/tmp/existing.m4a' }; + const adapter = makeAdapter({ + loadInitial: jest.fn().mockResolvedValue(existing), + deleteCommitted: jest.fn().mockRejectedValue(new Error('delete failed')), + }); + + const { result } = renderHook(() => useRecorder(adapter)); + await waitReady(result); + + await act(async () => { + await result.current.deleteCurrent(); + }); + + expect(adapter.deleteCommitted).toHaveBeenCalledWith(existing); + expect(result.current.status).toBe('review'); + expect(result.current.currentRecording?.id).toBe('existing'); + }); + it('reports blocked permission when the OS has suppressed the prompt', async () => { const expoAudio = jest.requireMock('expo-audio') as { getRecordingPermissionsAsync: jest.Mock; diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts index cfd4f4a..a7e1d06 100644 --- a/src/hooks/useRecorder.ts +++ b/src/hooks/useRecorder.ts @@ -380,8 +380,18 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { segmentStart === null ? 0 : Date.now() - segmentStart; const duration = baseElapsedRef.current + activeSegmentMs; - await recorder.stop(); - clearTick(); + try { + await recorder.stop(); + } catch (error) { + log.warn('Recorder stop failed while committing take', { + sessionKey: currentSessionKey, + error, + }); + setStatus(currentRecording ? 'review' : 'idle'); + return; + } finally { + clearTick(); + } const fileUri = recorder.uri; if (!fileUri) { @@ -446,7 +456,16 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { const deleteCurrent = useCallback(async () => { if (status !== 'review' || !currentRecording) return; stopPlayback(); - await adapterRef.current.deleteCommitted(currentRecording); + const currentSessionKey = sessionKeyRef.current; + try { + await adapterRef.current.deleteCommitted(currentRecording); + } catch (error) { + log.error('Failed to delete committed recording', { + sessionKey: currentSessionKey, + error, + }); + return; + } setCurrentRecording(null); setStatus('idle'); setElapsedMs(0); From fe0bb3c2c65f5f11c0f7f0d045d08b632aa48c9c Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Fri, 3 Jul 2026 01:42:27 -0300 Subject: [PATCH 33/55] [#49]: Reject reserved segments in recording storage paths Sanitize path segments that collapse to . or .. and validate relative keys before constructing File paths so traversal segments cannot slip through buildRecordingKey or resolveRecordingUri. Refs #49 Co-authored-by: Cursor --- src/services/recordingStorage.test.ts | 30 ++++++++++++++++++++++++ src/services/recordingStorage.ts | 33 +++++++++++++++++++++------ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/services/recordingStorage.test.ts b/src/services/recordingStorage.test.ts index e78fa44..c1acd5e 100644 --- a/src/services/recordingStorage.test.ts +++ b/src/services/recordingStorage.test.ts @@ -101,6 +101,21 @@ describe('buildRecordingKey', () => { buildRecordingKey({ ...baseParts, userId: 'a/b c', bookCode: 'x!y' }), ).toBe('recordings/ua_b_c/p45/X_Y/c001/v007/rec-1.m4a'); }); + + it('replaces reserved path segments (. and ..) with safe fallbacks', () => { + expect(buildRecordingKey({ ...baseParts, bookCode: '..' })).toBe( + 'recordings/u123/p45/UNK/c001/v007/rec-1.m4a', + ); + expect(buildRecordingKey({ ...baseParts, bookCode: '.' })).toBe( + 'recordings/u123/p45/UNK/c001/v007/rec-1.m4a', + ); + expect(buildRecordingKey({ ...baseParts, userId: '..' })).toBe( + 'recordings/uunknown/p45/GEN/c001/v007/rec-1.m4a', + ); + expect(buildRecordingKey({ ...baseParts, recordingId: '..' })).toBe( + 'recordings/u123/p45/GEN/c001/v007/unknown.m4a', + ); + }); }); describe('resolveRecordingUri', () => { @@ -119,6 +134,12 @@ describe('resolveRecordingUri', () => { ); expect(resolveRecordingUri('/tmp/legacy.m4a')).toBe('/tmp/legacy.m4a'); }); + + it('rejects relative keys containing reserved path segments', () => { + expect(() => resolveRecordingUri('recordings/u1/../secret.m4a')).toThrow( + 'Invalid recording path segment', + ); + }); }); describe('moveIntoStore', () => { @@ -143,6 +164,15 @@ describe('moveIntoStore', () => { sizeBytes: 4096, }); }); + + it('rejects keys containing reserved path segments', async () => { + await expect( + moveIntoStore({ + sourceUri: 'file:///cache/take.m4a', + key: 'recordings/u1/p2/../v001/rec-1.m4a', + }), + ).rejects.toThrow('Invalid recording path segment'); + }); }); describe('deleteRecordingFile', () => { diff --git a/src/services/recordingStorage.ts b/src/services/recordingStorage.ts index c053c48..0c3524f 100644 --- a/src/services/recordingStorage.ts +++ b/src/services/recordingStorage.ts @@ -44,9 +44,25 @@ function pad3(value: number): string { return String(value).padStart(3, '0'); } +const RESERVED_SEGMENTS = new Set(['', '.', '..']); + +function isReservedSegment(segment: string): boolean { + return RESERVED_SEGMENTS.has(segment); +} + /** Keeps path segments filesystem-safe without collapsing distinct values. */ -function sanitizeSegment(value: string): string { - return value.replace(/[^a-zA-Z0-9._-]/g, '_'); +function sanitizeSegment(value: string, fallback = 'unknown'): string { + const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, '_'); + return isReservedSegment(sanitized) ? fallback : sanitized; +} + +/** Rejects reserved segments before passing paths to Directory/File constructors. */ +function assertSafePathSegments(segments: string[]): void { + for (const segment of segments) { + if (isReservedSegment(segment)) { + throw new Error(`Invalid recording path segment: "${segment}"`); + } + } } /** Extracts a lowercase file extension from a uri, falling back to `.m4a`. */ @@ -62,12 +78,12 @@ export function extensionFromUri( export function recordingKeySegments(parts: RecordingKeyParts): string[] { return [ RECORDINGS_ROOT, - `u${sanitizeSegment(parts.userId || 'unknown')}`, + `u${sanitizeSegment(parts.userId, 'unknown')}`, `p${parts.projectId}`, - sanitizeSegment(parts.bookCode || 'UNK').toUpperCase(), + sanitizeSegment(parts.bookCode, 'UNK').toUpperCase(), `c${pad3(parts.chapterNumber)}`, `v${pad3(parts.verseNumber)}`, - `${sanitizeSegment(parts.recordingId)}.${ + `${sanitizeSegment(parts.recordingId, 'unknown')}.${ parts.extension ?? DEFAULT_EXTENSION }`, ]; @@ -87,7 +103,9 @@ export function resolveRecordingUri(pathOrKey: string): string { if (pathOrKey.startsWith('file:') || pathOrKey.startsWith('/')) { return pathOrKey; } - return new File(Paths.document, ...pathOrKey.split('/')).uri; + const segments = pathOrKey.split('/'); + assertSafePathSegments(segments); + return new File(Paths.document, ...segments).uri; } /** @@ -100,6 +118,7 @@ export async function moveIntoStore({ key, }: MoveIntoStoreArgs): Promise { const segments = key.split('/'); + assertSafePathSegments(segments); const fileName = segments[segments.length - 1]!; const dirSegments = segments.slice(0, -1); @@ -130,7 +149,7 @@ export function deleteUserTree(userId: string): void { const dir = new Directory( Paths.document, RECORDINGS_ROOT, - `u${sanitizeSegment(userId || 'unknown')}`, + `u${sanitizeSegment(userId, 'unknown')}`, ); if (dir.exists) dir.delete(); } catch (error) { From 1c219e2d9f3112d2df50c3945b8e3c9f5f735680 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Mon, 6 Jul 2026 17:32:50 -0300 Subject: [PATCH 34/55] [#49]: Guard tab switches during recording and fix discard cleanup Keep RecordTab mounted while switching Bible/Record tabs, prompt before leaving an in-progress take, use shared enums for tab/status literals, and delete finalized audio files when discarding straight from Recording. Refs #49 Co-authored-by: Cursor --- src/app/screens/DraftingScreen.tsx | 52 +++++- src/app/tabs/RecordTab.tsx | 10 +- .../tabs/drafting/record/RecordTab.test.tsx | 7 +- src/app/tabs/drafting/record/RecordTab.tsx | 33 +++- .../components/RecordingControls.test.tsx | 17 +- .../record/components/RecordingControls.tsx | 10 +- .../components/RecordingWaveform.test.tsx | 13 +- .../record/components/RecordingWaveform.tsx | 8 +- .../record/hooks/useRecordTabGuards.test.ts | 173 +++++++++++++++++- .../record/hooks/useRecordTabGuards.ts | 107 +++++++---- .../record/hooks/useVerseRecorder.test.ts | 15 +- src/components/layout/DraftingTabBar.tsx | 4 +- src/hooks/useRecorder.test.ts | 71 ++++--- src/hooks/useRecorder.ts | 64 ++++--- src/types/drafting/types.ts | 17 +- src/types/recording/types.ts | 6 + 16 files changed, 463 insertions(+), 144 deletions(-) create mode 100644 src/types/recording/types.ts diff --git a/src/app/screens/DraftingScreen.tsx b/src/app/screens/DraftingScreen.tsx index 6786b0d..94da30b 100644 --- a/src/app/screens/DraftingScreen.tsx +++ b/src/app/screens/DraftingScreen.tsx @@ -6,7 +6,7 @@ import { RecordTab } from '../tabs/RecordTab'; import { useSyncStatus } from '../../hooks/useSyncStatus'; import { onSyncComplete } from '../../services/syncEvents'; import { StackNavigationProp } from '@react-navigation/stack'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { RootStackParamList } from '../../types/navigation/types'; import { DraftingHeader } from '../../components/layout/DraftingHeader'; import { ChapterAssignmentData, VerseData } from '../../types/db/types'; @@ -24,8 +24,9 @@ import { import { SourceAudioPlayerBar } from '../../components/layout/SourceAudioPlayerBar'; import { DraftingTab, - DraftingTabBar, -} from '../../components/layout/DraftingTabBar'; + type TabSwitchGuardRef, +} from '../../types/drafting/types'; +import { DraftingTabBar } from '../../components/layout/DraftingTabBar'; import { getBibleTexts, getChapterAssignmentById, @@ -42,7 +43,7 @@ export default function DraftingScreen() { const { chapterId, chapterName } = useRoute().params; const [activeTab, setActiveTabState] = useState( - () => getLastActiveTab(chapterId) ?? 'bible', + () => getLastActiveTab(chapterId) ?? DraftingTab.Bible, ); const setActiveTab = useCallback( @@ -53,6 +54,22 @@ export default function DraftingScreen() { [chapterId], ); + const tabSwitchGuardRef = useRef(null); + + const handleTabChange = useCallback( + (tab: DraftingTab) => { + if (tab === activeTab) return; + const leaveRecord = + activeTab === DraftingTab.Record && tab === DraftingTab.Bible; + if (leaveRecord && tabSwitchGuardRef.current) { + tabSwitchGuardRef.current(() => setActiveTab(tab)); + return; + } + setActiveTab(tab); + }, + [activeTab, setActiveTab], + ); + const [verses, setVerses] = useState([]); const [chapterData, setChapterData] = useState( null, @@ -166,12 +183,29 @@ export default function DraftingScreen() { /> - {activeTab === 'bible' ? : } + + + + + + - + @@ -191,6 +225,12 @@ const styles = StyleSheet.create({ content: { flex: 1, }, + tabPanel: { + flex: 1, + }, + tabHidden: { + display: 'none', + }, centered: { flex: 1, alignItems: 'center', diff --git a/src/app/tabs/RecordTab.tsx b/src/app/tabs/RecordTab.tsx index 05fe02d..7eee011 100644 --- a/src/app/tabs/RecordTab.tsx +++ b/src/app/tabs/RecordTab.tsx @@ -2,13 +2,20 @@ import React, { useEffect, useState } from 'react'; import { useDraftingContext } from '../context/DraftingContext'; import { getBibleTextId, getProjectIdForProjectUnit } from '../../db/queries'; import { getActiveUserId } from '../../services/storage'; +import type { TabSwitchGuardRef } from '../../types/drafting/types'; import { RecordTab as RecordTabPanel } from './drafting/record/RecordTab'; +export type { TabSwitchGuardRef } from '../../types/drafting/types'; + +interface RecordTabProps { + tabSwitchGuardRef?: TabSwitchGuardRef; +} + /** * Record tab mount point for the drafting screen. Resolves recording metadata * from the chapter assignment and wires verse selection through DraftingContext. */ -export function RecordTab() { +export function RecordTab({ tabSwitchGuardRef }: RecordTabProps = {}) { const { verses, selectedVerse, @@ -69,6 +76,7 @@ export function RecordTab() { projectId={projectId} chapterAssignmentId={chapterAssignment.id} bookCode={chapterAssignment.bookCode ?? null} + tabSwitchGuardRef={tabSwitchGuardRef} /> ); } diff --git a/src/app/tabs/drafting/record/RecordTab.test.tsx b/src/app/tabs/drafting/record/RecordTab.test.tsx index 5fb5860..a11974f 100644 --- a/src/app/tabs/drafting/record/RecordTab.test.tsx +++ b/src/app/tabs/drafting/record/RecordTab.test.tsx @@ -9,6 +9,7 @@ import { } from '@testing-library/react-native'; import { RecordTab } from './RecordTab'; import type { VerseData } from '../../../../types/db/types'; +import { RecorderStatus } from '../../../../types/recording/types'; const mockUseVerseRecorder = jest.fn(); @@ -32,7 +33,7 @@ const VERSES: VerseData[] = [ function baseRecorderState() { return { - status: 'idle' as const, + status: RecorderStatus.Idle, elapsedMs: 0, permission: 'granted' as const, currentRecording: null, @@ -123,7 +124,7 @@ describe('RecordTab', () => { it('disables verse chevrons while recording even when navigation is possible', () => { mockUseVerseRecorder.mockReturnValue({ ...baseRecorderState(), - status: 'recording', + status: RecorderStatus.Recording, elapsedMs: 4_000, }); renderTab({ selectedVerseNumber: 2 }); @@ -242,7 +243,7 @@ describe('RecordTab', () => { const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); mockUseVerseRecorder.mockReturnValue({ ...baseRecorderState(), - status: 'review', + status: RecorderStatus.Review, currentRecording: reviewRecording(), }); diff --git a/src/app/tabs/drafting/record/RecordTab.tsx b/src/app/tabs/drafting/record/RecordTab.tsx index 21fefa2..2ff14be 100644 --- a/src/app/tabs/drafting/record/RecordTab.tsx +++ b/src/app/tabs/drafting/record/RecordTab.tsx @@ -3,6 +3,7 @@ import { Alert, StyleSheet, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { theme } from '../../../../theme'; import type { VerseData } from '../../../../types/db/types'; +import type { TabSwitchGuardRef } from '../../../../types/drafting/types'; import { RecordingControls } from './components/RecordingControls'; import { RecordingWaveform } from './components/RecordingWaveform'; import { SourceTextPanel } from './components/SourceTextPanel'; @@ -11,6 +12,7 @@ import { logger } from '../../../../utils/logger'; import { useRecordTabGuards } from './hooks/useRecordTabGuards'; import { useVerseRecorder } from './hooks/useVerseRecorder'; import { verseReference } from './utils/recordTabUtils'; +import { RecorderStatus } from '../../../../types/recording/types'; const log = logger.create('RecordTab'); @@ -31,6 +33,7 @@ interface RecordTabProps { projectId?: number | null; chapterAssignmentId?: number | null; bookCode?: string | null; + tabSwitchGuardRef?: TabSwitchGuardRef; } export function RecordTab({ @@ -44,6 +47,7 @@ export function RecordTab({ projectId, chapterAssignmentId, bookCode, + tabSwitchGuardRef, }: RecordTabProps) { const navigation = useNavigation(); const recorder = useVerseRecorder({ @@ -57,15 +61,24 @@ export function RecordTab({ }); const [deferElapsed, setDeferElapsed] = useState(false); - const { withPausedGuard, ensureMicPermission } = useRecordTabGuards({ - status: recorder.status, - permission: recorder.permission, - requestPermission: recorder.requestPermission, - discardPaused: recorder.discardPaused, - navigation: navigation as unknown as Parameters< - typeof useRecordTabGuards - >[0]['navigation'], - }); + const { withPausedGuard, withTabSwitchGuard, ensureMicPermission } = + useRecordTabGuards({ + status: recorder.status, + permission: recorder.permission, + requestPermission: recorder.requestPermission, + discardPaused: recorder.discardPaused, + navigation: navigation as unknown as Parameters< + typeof useRecordTabGuards + >[0]['navigation'], + }); + + useEffect(() => { + if (!tabSwitchGuardRef) return; + tabSwitchGuardRef.current = withTabSwitchGuard; + return () => { + tabSwitchGuardRef.current = null; + }; + }, [tabSwitchGuardRef, withTabSwitchGuard]); // Present as soon as the recorder has loaded its state, but cap the wait at // PRESENTATION_DEFER_MS so a slow/stalled load still surfaces the UI. @@ -94,7 +107,7 @@ export function RecordTab({ const canGoNext = verseIndex >= 0 && verseIndex < verses.length - 1; // Verse navigation is locked mid-recording so the take stays anchored to // the verse the user started on. Paused/idle/review keep their own guards. - const isRecordingLocked = recorder.status === 'recording'; + const isRecordingLocked = recorder.status === RecorderStatus.Recording; const prevDisabled = !canGoPrev || isRecordingLocked; const nextDisabled = !canGoNext || isRecordingLocked; diff --git a/src/app/tabs/drafting/record/components/RecordingControls.test.tsx b/src/app/tabs/drafting/record/components/RecordingControls.test.tsx index 95d210c..6f3f40b 100644 --- a/src/app/tabs/drafting/record/components/RecordingControls.test.tsx +++ b/src/app/tabs/drafting/record/components/RecordingControls.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { RecordingControls } from './RecordingControls'; +import { RecorderStatus } from '../../../../../types/recording/types'; const REFERENCE = 'Mark 14:3'; @@ -8,7 +9,7 @@ describe('RecordingControls', () => { it('renders the idle record button with a label and disabled play hint', () => { render( { it('shows pause and stop controls with a duration and tip when recording', () => { render( { it('shows the paused tip instead of the recording tip while paused', () => { render( { it('shows the resume button while paused with a live session', () => { render( { it('shows discard instead of resume for a rehydrated paused take', () => { render( { render( { render( { it('shows a pause affordance while the draft is playing', () => { render( - {status === 'idle' && ( + {status === RecorderStatus.Idle && ( )} - {status === 'recording' && ( + {status === RecorderStatus.Recording && ( {formatDuration(elapsedMs)} @@ -120,7 +120,7 @@ export function RecordingControls({ )} - {status === 'paused' && ( + {status === RecorderStatus.Paused && ( {formatDuration(elapsedMs)} @@ -180,7 +180,7 @@ export function RecordingControls({ )} - {status === 'review' && ( + {status === RecorderStatus.Review && ( { it('renders nothing for idle status', () => { - render(); + render(); expect(screen.queryByTestId('record-waveform-live')).toBeNull(); expect(screen.queryByTestId('record-waveform-static')).toBeNull(); }); it('renders the live waveform while recording', () => { - render(); + render( + , + ); expect(screen.getByTestId('record-waveform-live')).toBeTruthy(); expect(screen.queryByTestId('record-waveform-static')).toBeNull(); }); it('renders the live waveform while paused', () => { - render(); + render( + , + ); expect(screen.getByTestId('record-waveform-live')).toBeTruthy(); }); it('renders the static waveform in review', () => { - render(); + render(); expect(screen.queryByTestId('record-waveform-live')).toBeNull(); expect(screen.getByTestId('record-waveform-static')).toBeTruthy(); diff --git a/src/app/tabs/drafting/record/components/RecordingWaveform.tsx b/src/app/tabs/drafting/record/components/RecordingWaveform.tsx index a4400f5..8e0d8e8 100644 --- a/src/app/tabs/drafting/record/components/RecordingWaveform.tsx +++ b/src/app/tabs/drafting/record/components/RecordingWaveform.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { StyleSheet, View, type ViewStyle } from 'react-native'; -import type { RecorderStatus } from '../../../../../hooks/useRecorder'; +import { RecorderStatus } from '../../../../../types/recording/types'; import { theme } from '../../../../../theme'; const LIVE_WAVEFORM_BARS = 22; @@ -29,8 +29,8 @@ export function RecordingWaveform({ status, elapsedMs, }: RecordingWaveformProps) { - if (status === 'recording' || status === 'paused') { - const active = status === 'recording'; + if (status === RecorderStatus.Recording || status === RecorderStatus.Paused) { + const active = status === RecorderStatus.Recording; return ( jest.fn()), @@ -18,7 +19,7 @@ describe('useRecordTabGuards', () => { const { result } = renderHook(() => useRecordTabGuards({ - status: 'paused', + status: RecorderStatus.Paused, permission: 'granted', requestPermission: jest.fn(), discardPaused: jest.fn().mockResolvedValue(undefined), @@ -43,7 +44,7 @@ describe('useRecordTabGuards', () => { const { result } = renderHook(() => useRecordTabGuards({ - status: 'idle', + status: RecorderStatus.Idle, permission: 'granted', requestPermission: jest.fn(), discardPaused: jest.fn().mockResolvedValue(undefined), @@ -63,7 +64,7 @@ describe('useRecordTabGuards', () => { const { result } = renderHook(() => useRecordTabGuards({ - status: 'paused', + status: RecorderStatus.Paused, permission: 'granted', requestPermission: jest.fn(), discardPaused, @@ -97,7 +98,7 @@ describe('useRecordTabGuards', () => { const { result } = renderHook(() => useRecordTabGuards({ - status: 'paused', + status: RecorderStatus.Paused, permission: 'granted', requestPermission: jest.fn(), discardPaused, @@ -150,7 +151,7 @@ describe('useRecordTabGuards', () => { renderHook(() => useRecordTabGuards({ - status: 'paused', + status: RecorderStatus.Paused, permission: 'granted', requestPermission: jest.fn(), discardPaused, @@ -182,7 +183,7 @@ describe('useRecordTabGuards', () => { alertSpy.mockRestore(); }); - it.each(['paused', 'recording'] as const)( + it.each([RecorderStatus.Paused, RecorderStatus.Recording] as const)( 'registers a beforeRemove listener while %s', status => { renderHook(() => @@ -224,7 +225,7 @@ describe('useRecordTabGuards', () => { renderHook(() => useRecordTabGuards({ - status: 'recording', + status: RecorderStatus.Recording, permission: 'granted', requestPermission: jest.fn(), discardPaused, @@ -260,10 +261,164 @@ describe('useRecordTabGuards', () => { alertSpy.mockRestore(); }); + describe('withTabSwitchGuard', () => { + it('shows a paused alert with tab copy instead of running the action', () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const action = jest.fn(); + + const { result } = renderHook(() => + useRecordTabGuards({ + status: RecorderStatus.Paused, + permission: 'granted', + requestPermission: jest.fn(), + discardPaused: jest.fn().mockResolvedValue(undefined), + navigation: mockNavigation as never, + }), + ); + + result.current.withTabSwitchGuard(action); + + expect(alertSpy).toHaveBeenCalledWith( + 'Recording in progress', + expect.stringContaining('switching tabs'), + expect.any(Array), + ); + expect(action).not.toHaveBeenCalled(); + + alertSpy.mockRestore(); + }); + + it('discards the paused take before switching tabs', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const discardPaused = jest.fn().mockResolvedValue(undefined); + const action = jest.fn(); + + const { result } = renderHook(() => + useRecordTabGuards({ + status: RecorderStatus.Paused, + permission: 'granted', + requestPermission: jest.fn(), + discardPaused, + navigation: mockNavigation as never, + }), + ); + + result.current.withTabSwitchGuard(action); + + const buttons = alertSpy.mock.calls[0]![2] as Array<{ + text: string; + onPress?: () => Promise; + }>; + const discardButton = buttons.find(button => button.text === 'Discard'); + await act(async () => { + await discardButton?.onPress?.(); + }); + + expect(discardPaused).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(1); + + alertSpy.mockRestore(); + }); + + it('prompts to discard an active recording before switching tabs', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const discardPaused = jest.fn().mockResolvedValue(undefined); + const action = jest.fn(); + + const { result } = renderHook(() => + useRecordTabGuards({ + status: RecorderStatus.Recording, + permission: 'granted', + requestPermission: jest.fn(), + discardPaused, + navigation: mockNavigation as never, + }), + ); + + result.current.withTabSwitchGuard(action); + + expect(alertSpy).toHaveBeenCalledWith( + 'Recording in progress', + expect.stringContaining('switching tabs'), + expect.any(Array), + ); + expect(action).not.toHaveBeenCalled(); + + const buttons = alertSpy.mock.calls[0]![2] as Array<{ + text: string; + onPress?: () => void | Promise; + }>; + const discardButton = buttons.find(button => button.text === 'Discard'); + await act(async () => { + await discardButton?.onPress?.(); + }); + + expect(discardPaused).toHaveBeenCalledTimes(1); + expect(action).toHaveBeenCalledTimes(1); + + alertSpy.mockRestore(); + }); + + it('does not switch tabs when discard fails', async () => { + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + const discardPaused = jest + .fn() + .mockRejectedValue(new Error('discard failed')); + const action = jest.fn(); + + const { result } = renderHook(() => + useRecordTabGuards({ + status: RecorderStatus.Recording, + permission: 'granted', + requestPermission: jest.fn(), + discardPaused, + navigation: mockNavigation as never, + }), + ); + + result.current.withTabSwitchGuard(action); + + const buttons = alertSpy.mock.calls[0]![2] as Array<{ + text: string; + onPress?: () => void | Promise; + }>; + const discardButton = buttons.find(button => button.text === 'Discard'); + await act(async () => { + await discardButton?.onPress?.(); + }); + + expect(discardPaused).toHaveBeenCalledTimes(1); + expect(action).not.toHaveBeenCalled(); + + alertSpy.mockRestore(); + }); + + it.each([RecorderStatus.Idle, RecorderStatus.Review] as const)( + 'runs the action immediately when status is %s', + status => { + const action = jest.fn(); + + const { result } = renderHook(() => + useRecordTabGuards({ + status, + permission: 'granted', + requestPermission: jest.fn(), + discardPaused: jest.fn().mockResolvedValue(undefined), + navigation: mockNavigation as never, + }), + ); + + result.current.withTabSwitchGuard(action); + + expect(action).toHaveBeenCalledTimes(1); + }, + ); + }); + it('ensureMicPermission returns true when permission is granted', async () => { const { result } = renderHook(() => useRecordTabGuards({ - status: 'idle', + status: RecorderStatus.Idle, permission: 'granted', requestPermission: jest.fn(), discardPaused: jest.fn().mockResolvedValue(undefined), @@ -280,7 +435,7 @@ describe('useRecordTabGuards', () => { const { result } = renderHook(() => useRecordTabGuards({ - status: 'idle', + status: RecorderStatus.Idle, permission: 'blocked', requestPermission, discardPaused: jest.fn().mockResolvedValue(undefined), diff --git a/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts index de2fe39..35b45a5 100644 --- a/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts +++ b/src/app/tabs/drafting/record/hooks/useRecordTabGuards.ts @@ -1,9 +1,8 @@ import { useCallback, useEffect } from 'react'; import { Alert, Linking } from 'react-native'; -import type { - PermissionState, - RecorderStatus, -} from '../../../../../hooks/useRecorder'; +import { PermissionState } from '../../../../../hooks/useRecorder'; +import { RecorderStatus } from '../../../../../types/recording/types'; +import { GuardContext } from '../../../../../types/drafting/types'; import { logger } from '../../../../../utils/logger'; const log = logger.create('useRecordTabGuards'); @@ -43,6 +42,60 @@ async function discardPausedThen( } } +function pausedMessage(context: GuardContext): string { + switch (context) { + case GuardContext.Verse: + return 'Resume or discard the paused take before switching verses.'; + case GuardContext.Tab: + return 'Resume or discard the paused take before switching tabs.'; + case GuardContext.Leave: + return 'You have a paused recording. Resume it or discard the take before leaving.'; + } +} + +function recordingMessage(context: GuardContext): string { + switch (context) { + case GuardContext.Tab: + return 'You are actively recording. Discard the take before switching tabs.'; + case GuardContext.Leave: + default: + return 'You are actively recording. Discard the take before leaving.'; + } +} + +function promptBeforeAction( + status: RecorderStatus, + context: GuardContext, + discardPaused: () => Promise, + action: () => void, +) { + if (status === RecorderStatus.Paused) { + Alert.alert('Recording in progress', pausedMessage(context), [ + { text: 'Resume', style: 'cancel' }, + { + text: 'Discard', + style: 'destructive', + onPress: () => discardPausedThen(discardPaused, action), + }, + ]); + return; + } + + if (status === RecorderStatus.Recording) { + Alert.alert('Recording in progress', recordingMessage(context), [ + { text: 'Keep recording', style: 'cancel' }, + { + text: 'Discard', + style: 'destructive', + onPress: () => discardPausedThen(discardPaused, action), + }, + ]); + return; + } + + action(); +} + function showMicBlockedAlert() { Alert.alert( 'Microphone access required', @@ -68,19 +121,8 @@ export function useRecordTabGuards({ }: UseRecordTabGuardsArgs) { const withPausedGuard = useCallback( (action: () => void) => { - if (status === 'paused') { - Alert.alert( - 'Recording in progress', - 'Resume or discard the paused take before switching verses.', - [ - { text: 'Resume', style: 'cancel' }, - { - text: 'Discard', - style: 'destructive', - onPress: () => discardPausedThen(discardPaused, action), - }, - ], - ); + if (status === RecorderStatus.Paused) { + promptBeforeAction(status, GuardContext.Verse, discardPaused, action); return; } action(); @@ -88,31 +130,24 @@ export function useRecordTabGuards({ [discardPaused, status], ); + const withTabSwitchGuard = useCallback( + (action: () => void) => { + promptBeforeAction(status, GuardContext.Tab, discardPaused, action); + }, + [discardPaused, status], + ); + useEffect(() => { - if (status !== 'paused' && status !== 'recording') return; + if (status !== RecorderStatus.Paused && status !== RecorderStatus.Recording) + return; - const isRecording = status === 'recording'; const beforeRemove = (event: { preventDefault: () => void; data: { action: unknown }; }) => { event.preventDefault(); - Alert.alert( - 'Recording in progress', - isRecording - ? 'You are actively recording. Discard the take before leaving.' - : 'You have a paused recording. Resume it or discard the take before leaving.', - [ - { text: isRecording ? 'Keep recording' : 'Resume', style: 'cancel' }, - { - text: 'Discard', - style: 'destructive', - onPress: () => - discardPausedThen(discardPaused, () => - navigation.dispatch(event.data.action), - ), - }, - ], + promptBeforeAction(status, GuardContext.Leave, discardPaused, () => + navigation.dispatch(event.data.action), ); }; @@ -132,5 +167,5 @@ export function useRecordTabGuards({ return false; }, [permission, requestPermission]); - return { withPausedGuard, ensureMicPermission }; + return { withPausedGuard, withTabSwitchGuard, ensureMicPermission }; } diff --git a/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts b/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts index 21bc850..df729ac 100644 --- a/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts +++ b/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts @@ -1,6 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; import { AppState } from 'react-native'; import { useVerseRecorder } from './useVerseRecorder'; +import { RecorderStatus } from '../../../../../types/recording/types'; const mockRecorder = { currentTime: 0, @@ -146,7 +147,7 @@ describe('useVerseRecorder', () => { await waitReady(result); expect(mockGetLatestRecordingForVerse).toHaveBeenCalledWith(42); - expect(result.current.status).toBe('review'); + expect(result.current.status).toBe(RecorderStatus.Review); expect(result.current.currentRecording?.id).toBe('existing'); }); @@ -228,7 +229,7 @@ describe('useVerseRecorder', () => { }), ); expect(mockClearPausedTake).toHaveBeenCalledWith(42); - expect(result.current.status).toBe('review'); + expect(result.current.status).toBe(RecorderStatus.Review); } finally { jest.useRealTimers(); } @@ -254,7 +255,7 @@ describe('useVerseRecorder', () => { }); expect(mockDeleteRecordingFile).toHaveBeenCalledWith(MOVED_KEY); - expect(result.current.status).toBe('idle'); + expect(result.current.status).toBe(RecorderStatus.Idle); expect(result.current.currentRecording).toBeNull(); } finally { jest.useRealTimers(); @@ -271,7 +272,7 @@ describe('useVerseRecorder', () => { }); expect(mockDeleteRecordingById).toHaveBeenCalledWith('existing'); - expect(result.current.status).toBe('idle'); + expect(result.current.status).toBe(RecorderStatus.Idle); expect(result.current.currentRecording).toBeNull(); }); @@ -286,7 +287,7 @@ describe('useVerseRecorder', () => { const { result } = renderHook(() => useVerseRecorder({ bibleTextId: 42 })); await waitReady(result); - expect(result.current.status).toBe('paused'); + expect(result.current.status).toBe(RecorderStatus.Paused); expect(result.current.canResume).toBe(false); await act(async () => { @@ -297,14 +298,14 @@ describe('useVerseRecorder', () => { 'file:///docs/partial-take.m4a', ); expect(mockClearPausedTake).toHaveBeenCalledWith(42); - expect(result.current.status).toBe('idle'); + expect(result.current.status).toBe(RecorderStatus.Idle); }); it('resolves the stored key to an absolute uri for playback', async () => { mockGetLatestRecordingForVerse.mockResolvedValueOnce(existingRecording()); const { result } = renderHook(() => useVerseRecorder({ bibleTextId: 42 })); await waitReady(result); - expect(result.current.status).toBe('review'); + expect(result.current.status).toBe(RecorderStatus.Review); await act(async () => { await result.current.togglePlayback(); diff --git a/src/components/layout/DraftingTabBar.tsx b/src/components/layout/DraftingTabBar.tsx index 4b1790f..522eb15 100644 --- a/src/components/layout/DraftingTabBar.tsx +++ b/src/components/layout/DraftingTabBar.tsx @@ -12,8 +12,8 @@ interface DraftingTabBarProps { } const TABS: { id: DraftingTab; label: string; Icon: LucideIcon }[] = [ - { id: 'bible', label: 'Bible', Icon: Headphones }, - { id: 'record', label: 'Record', Icon: Mic }, + { id: DraftingTab.Bible, label: 'Bible', Icon: Headphones }, + { id: DraftingTab.Record, label: 'Record', Icon: Mic }, ]; export function DraftingTabBar({ diff --git a/src/hooks/useRecorder.test.ts b/src/hooks/useRecorder.test.ts index 070bfbf..8fcb0c8 100644 --- a/src/hooks/useRecorder.test.ts +++ b/src/hooks/useRecorder.test.ts @@ -1,6 +1,10 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; import { AppState } from 'react-native'; -import { useRecorder, type RecorderAdapter } from './useRecorder'; +import { + useRecorder, + type RecorderAdapter, + RecorderStatus, +} from './useRecorder'; interface FakeTake { id: string; @@ -101,7 +105,7 @@ describe('useRecorder', () => { const { result } = renderHook(() => useRecorder(adapter)); // A null session never becomes ready and never queries the adapter. await waitFor(() => expect(adapter.loadInitial).not.toHaveBeenCalled()); - expect(result.current.status).toBe('idle'); + expect(result.current.status).toBe(RecorderStatus.Idle); expect(result.current.currentRecording).toBeNull(); expect(result.current.isReady).toBe(false); }); @@ -110,7 +114,7 @@ describe('useRecorder', () => { const adapter = makeAdapter(); const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); - expect(result.current.status).toBe('idle'); + expect(result.current.status).toBe(RecorderStatus.Idle); expect(result.current.currentRecording).toBeNull(); expect(adapter.loadInitial).toHaveBeenCalledWith(42); }); @@ -122,7 +126,7 @@ describe('useRecorder', () => { }); const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); - expect(result.current.status).toBe('review'); + expect(result.current.status).toBe(RecorderStatus.Review); expect(result.current.currentRecording?.id).toBe('existing'); }); @@ -137,7 +141,7 @@ describe('useRecorder', () => { }); const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); - expect(result.current.status).toBe('paused'); + expect(result.current.status).toBe(RecorderStatus.Paused); expect(result.current.elapsedMs).toBe(4500); expect(result.current.canResume).toBe(false); }); @@ -159,7 +163,7 @@ describe('useRecorder', () => { }); expect(mockRecorder.record).not.toHaveBeenCalled(); - expect(result.current.status).toBe('paused'); + expect(result.current.status).toBe(RecorderStatus.Paused); }); it('transitions idle -> recording on start', async () => { @@ -173,7 +177,7 @@ describe('useRecorder', () => { expect(mockRecorder.prepareToRecordAsync).toHaveBeenCalled(); expect(mockRecorder.record).toHaveBeenCalled(); - expect(result.current.status).toBe('recording'); + expect(result.current.status).toBe(RecorderStatus.Recording); }); it('accumulates only active recording time across pause and resume', async () => { @@ -203,7 +207,7 @@ describe('useRecorder', () => { sessionToken: expect.any(String), }), ); - expect(result.current.status).toBe('paused'); + expect(result.current.status).toBe(RecorderStatus.Paused); expect(result.current.elapsedMs).toBe(2500); expect(result.current.canResume).toBe(true); @@ -212,7 +216,7 @@ describe('useRecorder', () => { await act(async () => { await result.current.resume(); }); - expect(result.current.status).toBe('recording'); + expect(result.current.status).toBe(RecorderStatus.Recording); expect(result.current.elapsedMs).toBe(2500); // Record for another 3s and stop; total active time = 5.5s, not 15.5s. @@ -227,7 +231,7 @@ describe('useRecorder', () => { durationMs: 5500, }); expect(adapter.clearPaused).toHaveBeenCalled(); - expect(result.current.status).toBe('review'); + expect(result.current.status).toBe(RecorderStatus.Review); expect(result.current.currentRecording?.id).toBe('take-1'); } finally { jest.useRealTimers(); @@ -242,13 +246,13 @@ describe('useRecorder', () => { const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); - expect(result.current.status).toBe('review'); + expect(result.current.status).toBe(RecorderStatus.Review); await act(async () => { await result.current.reRecord(); }); expect(mockRecorder.record).toHaveBeenCalled(); - expect(result.current.status).toBe('recording'); + expect(result.current.status).toBe(RecorderStatus.Recording); mockRecorder.currentTime = 1; await act(async () => { @@ -256,7 +260,7 @@ describe('useRecorder', () => { }); expect(adapter.onCommit).toHaveBeenCalledTimes(1); - expect(result.current.status).toBe('review'); + expect(result.current.status).toBe(RecorderStatus.Review); expect(result.current.currentRecording?.id).toBe('take-1'); }); @@ -276,7 +280,7 @@ describe('useRecorder', () => { expect(adapter.onCommit).toHaveBeenCalled(); expect(adapter.clearPaused).not.toHaveBeenCalled(); - expect(result.current.status).toBe('idle'); + expect(result.current.status).toBe(RecorderStatus.Idle); expect(result.current.currentRecording).toBeNull(); }); @@ -294,7 +298,7 @@ describe('useRecorder', () => { }); expect(adapter.onCommit).not.toHaveBeenCalled(); - expect(result.current.status).toBe('idle'); + expect(result.current.status).toBe(RecorderStatus.Idle); }); it('returns to review when recorder.stop throws during re-record', async () => { @@ -314,7 +318,7 @@ describe('useRecorder', () => { }); expect(adapter.onCommit).not.toHaveBeenCalled(); - expect(result.current.status).toBe('review'); + expect(result.current.status).toBe(RecorderStatus.Review); expect(result.current.currentRecording?.id).toBe('existing'); }); @@ -332,7 +336,7 @@ describe('useRecorder', () => { }); expect(adapter.deleteCommitted).toHaveBeenCalledWith(existing); - expect(result.current.status).toBe('idle'); + expect(result.current.status).toBe(RecorderStatus.Idle); expect(result.current.currentRecording).toBeNull(); }); @@ -351,7 +355,7 @@ describe('useRecorder', () => { }); expect(adapter.deleteCommitted).toHaveBeenCalledWith(existing); - expect(result.current.status).toBe('review'); + expect(result.current.status).toBe(RecorderStatus.Review); expect(result.current.currentRecording?.id).toBe('existing'); }); @@ -407,7 +411,7 @@ describe('useRecorder', () => { const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); - expect(result.current.status).toBe('review'); + expect(result.current.status).toBe(RecorderStatus.Review); expect(result.current.isPlaying).toBe(false); await act(async () => { @@ -440,7 +444,7 @@ describe('useRecorder', () => { it('does not play when there is no take to review', async () => { const { result } = renderHook(() => useRecorder(makeAdapter())); await waitReady(result); - expect(result.current.status).toBe('idle'); + expect(result.current.status).toBe(RecorderStatus.Idle); await act(async () => { await result.current.togglePlayback(); @@ -504,7 +508,7 @@ describe('useRecorder', () => { expect(mockRecorder.pause).toHaveBeenCalled(); expect(adapter.persistPaused).toHaveBeenCalled(); - expect(result.current.status).toBe('paused'); + expect(result.current.status).toBe(RecorderStatus.Paused); addSpy.mockRestore(); }); @@ -518,6 +522,27 @@ describe('useRecorder', () => { ); }); + it('unlinks the finalized file when an active recording is discarded without a paused marker', async () => { + const adapter = makeAdapter(); + const { result } = renderHook(() => useRecorder(adapter)); + await waitReady(result); + + await act(async () => { + await result.current.start(); + }); + expect(result.current.status).toBe(RecorderStatus.Recording); + + await act(async () => { + await result.current.discardPaused(); + }); + + expect(adapter.deletePausedFile).toHaveBeenCalledWith( + 'file:///tmp/take-1.m4a', + ); + expect(adapter.clearPaused).toHaveBeenCalled(); + expect(result.current.status).toBe(RecorderStatus.Idle); + }); + it('unlinks the durable partial file when a paused take is discarded', async () => { const adapter = makeAdapter({ loadPaused: jest.fn().mockReturnValue({ @@ -530,7 +555,7 @@ describe('useRecorder', () => { const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); - expect(result.current.status).toBe('paused'); + expect(result.current.status).toBe(RecorderStatus.Paused); await act(async () => { await result.current.discardPaused(); @@ -540,6 +565,6 @@ describe('useRecorder', () => { 'file:///docs/partial-take.m4a', ); expect(adapter.clearPaused).toHaveBeenCalled(); - expect(result.current.status).toBe('idle'); + expect(result.current.status).toBe(RecorderStatus.Idle); }); }); diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts index a7e1d06..28743dc 100644 --- a/src/hooks/useRecorder.ts +++ b/src/hooks/useRecorder.ts @@ -13,7 +13,9 @@ import { useDraftPlayback } from './useDraftPlayback'; const log = logger.create('useRecorder'); -export type RecorderStatus = 'idle' | 'recording' | 'paused' | 'review'; +import { RecorderStatus } from '../types/recording/types'; + +export { RecorderStatus }; /** * Microphone permission state. * - `unknown` — hook still probing the OS. @@ -143,7 +145,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { directory: 'document', }); - const [status, setStatus] = useState('idle'); + const [status, setStatus] = useState(RecorderStatus.Idle); const [permission, setPermission] = useState('unknown'); const [elapsedMs, setElapsedMs] = useState(0); const [currentRecording, setCurrentRecording] = useState(null); @@ -213,7 +215,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { clearTick(); if (sessionKey === null) { - setStatus('idle'); + setStatus(RecorderStatus.Idle); setCurrentRecording(null); return; } @@ -233,12 +235,12 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { // the UI should steer the user toward discardPaused instead. liveSessionTokenRef.current = null; setCanResume(false); - setStatus('paused'); + setStatus(RecorderStatus.Paused); setElapsedMs(paused.elapsedMs); baseElapsedRef.current = paused.elapsedMs; startedAtRef.current = paused.startedAt; } else { - setStatus(latest ? 'review' : 'idle'); + setStatus(latest ? RecorderStatus.Review : RecorderStatus.Idle); } setIsReady(true); } @@ -271,7 +273,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { const subscription = AppState.addEventListener( 'change', (nextState: AppStateStatus) => { - if (nextState !== 'active' && status === 'recording') { + if (nextState !== 'active' && status === RecorderStatus.Recording) { pauseInternal().catch(error => log.error('Auto-pause on background failed', { error }), ); @@ -323,7 +325,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { startedAtRef.current = new Date().toISOString(); setElapsedMs(0); recorder.record(); - setStatus('recording'); + setStatus(RecorderStatus.Recording); startTicking(); }, [recorder, startTicking]); @@ -340,7 +342,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { const pauseInternal = useCallback(async () => { const currentSessionKey = sessionKeyRef.current; if (currentSessionKey === null) return; - if (status !== 'recording') return; + if (status !== RecorderStatus.Recording) return; const segmentStart = runningSinceRef.current; const segmentElapsed = @@ -351,7 +353,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { baseElapsedRef.current = finalElapsed; runningSinceRef.current = null; setElapsedMs(finalElapsed); - setStatus('paused'); + setStatus(RecorderStatus.Paused); setCanResume(liveSessionTokenRef.current !== null); persistPausedMarker(recorder, finalElapsed); }, [clearTick, persistPausedMarker, recorder, status]); @@ -361,10 +363,14 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { }, [pauseInternal]); const resume = useCallback(async () => { - if (status !== 'paused' || liveSessionTokenRef.current === null) return; + if ( + status !== RecorderStatus.Paused || + liveSessionTokenRef.current === null + ) + return; recorder.record(); runningSinceRef.current = Date.now(); - setStatus('recording'); + setStatus(RecorderStatus.Recording); setCanResume(false); startTicking(); }, [recorder, startTicking, status]); @@ -387,7 +393,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { sessionKey: currentSessionKey, error, }); - setStatus(currentRecording ? 'review' : 'idle'); + setStatus(currentRecording ? RecorderStatus.Review : RecorderStatus.Idle); return; } finally { clearTick(); @@ -396,7 +402,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { const fileUri = recorder.uri; if (!fileUri) { log.error('Recorder returned no URI on stop; skipping commit'); - setStatus(currentRecording ? 'review' : 'idle'); + setStatus(currentRecording ? RecorderStatus.Review : RecorderStatus.Idle); return; } @@ -411,12 +417,12 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { sessionKey: currentSessionKey, error, }); - setStatus(currentRecording ? 'review' : 'idle'); + setStatus(currentRecording ? RecorderStatus.Review : RecorderStatus.Idle); return; } if (!committed) { - setStatus(currentRecording ? 'review' : 'idle'); + setStatus(currentRecording ? RecorderStatus.Review : RecorderStatus.Idle); return; } @@ -428,17 +434,22 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { setCanResume(false); setElapsedMs(duration); setCurrentRecording(committed); - setStatus('review'); + setStatus(RecorderStatus.Review); }, [clearTick, currentRecording, recorder]); const stop = useCallback(async () => { - if (status !== 'recording' && status !== 'paused') return; - if (status === 'paused' && liveSessionTokenRef.current === null) return; + if (status !== RecorderStatus.Recording && status !== RecorderStatus.Paused) + return; + if ( + status === RecorderStatus.Paused && + liveSessionTokenRef.current === null + ) + return; await commitRecording(); }, [commitRecording, status]); const reRecord = useCallback(async () => { - if (status !== 'review') return; + if (status !== RecorderStatus.Review) return; stopPlayback(); if (permission !== 'granted') { const { granted } = await requestPermission(); @@ -454,7 +465,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { ]); const deleteCurrent = useCallback(async () => { - if (status !== 'review' || !currentRecording) return; + if (status !== RecorderStatus.Review || !currentRecording) return; stopPlayback(); const currentSessionKey = sessionKeyRef.current; try { @@ -467,7 +478,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { return; } setCurrentRecording(null); - setStatus('idle'); + setStatus(RecorderStatus.Idle); setElapsedMs(0); baseElapsedRef.current = 0; runningSinceRef.current = null; @@ -482,9 +493,12 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { try { await recorder.stop(); } catch (error) { - log.warn('Recorder stop while discarding paused take failed', { error }); + log.warn('Recorder stop while discarding in-progress take failed', { + error, + }); } - if (marker?.fileUri) adapterRef.current.deletePausedFile(marker.fileUri); + const fileUri = marker?.fileUri ?? recorder.uri; + if (fileUri) adapterRef.current.deletePausedFile(fileUri); adapterRef.current.clearPaused(); clearTick(); baseElapsedRef.current = 0; @@ -493,11 +507,11 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { liveSessionTokenRef.current = null; setCanResume(false); setElapsedMs(0); - setStatus(currentRecording ? 'review' : 'idle'); + setStatus(currentRecording ? RecorderStatus.Review : RecorderStatus.Idle); }, [clearTick, currentRecording, recorder]); const togglePlayback = useCallback(async () => { - if (status !== 'review' || !currentRecording) return; + if (status !== RecorderStatus.Review || !currentRecording) return; await togglePlaybackInternal(); }, [currentRecording, status, togglePlaybackInternal]); diff --git a/src/types/drafting/types.ts b/src/types/drafting/types.ts index ce17a46..59cc625 100644 --- a/src/types/drafting/types.ts +++ b/src/types/drafting/types.ts @@ -1 +1,16 @@ -export type DraftingTab = 'bible' | 'record'; +export enum DraftingTab { + Bible = 'bible', + Record = 'record', +} + +export enum GuardContext { + Verse = 'verse', + Tab = 'tab', + Leave = 'leave', +} + +export type TabSwitchGuard = (action: () => void) => void; + +export interface TabSwitchGuardRef { + current: TabSwitchGuard | null; +} diff --git a/src/types/recording/types.ts b/src/types/recording/types.ts new file mode 100644 index 0000000..5172cc4 --- /dev/null +++ b/src/types/recording/types.ts @@ -0,0 +1,6 @@ +export enum RecorderStatus { + Idle = 'idle', + Recording = 'recording', + Paused = 'paused', + Review = 'review', +} From 9bb7fe5e4dc2c14b1bfc9dcbcfc56987e25af0b3 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Tue, 7 Jul 2026 08:26:57 -0300 Subject: [PATCH 35/55] [#49]: Toast when verse has no bible_text id on record Surface the otherwise-silent dead record button when the selected verse hasn't resolved a bible_text id (sync gap / unstable backend contract). Temporary stopgap until the team settles the session-key / bible_text_id approach once sync is stable. Refs #49 --- src/app/tabs/drafting/record/RecordTab.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/app/tabs/drafting/record/RecordTab.tsx b/src/app/tabs/drafting/record/RecordTab.tsx index 2ff14be..cbfb7c9 100644 --- a/src/app/tabs/drafting/record/RecordTab.tsx +++ b/src/app/tabs/drafting/record/RecordTab.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Alert, StyleSheet, View } from 'react-native'; +import { Alert, StyleSheet, ToastAndroid, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { theme } from '../../../../theme'; import type { VerseData } from '../../../../types/db/types'; @@ -129,6 +129,17 @@ export function RecordTab({ } async function handleStartPress() { + // TEMP: the source verse row hasn't resolved a bible_text id (sync gap / + // unstable backend contract), which leaves the recorder inert and the record + // button silently dead. Surface it until the team settles the session-key / + // bible_text_id approach once sync is stable. + if (bibleTextIdForSelectedVerse === null) { + ToastAndroid.show( + 'No bible text id for this verse yet.', + ToastAndroid.SHORT, + ); + return; + } if (!(await ensureMicPermission())) return; await recorder.start(); } From 8f6195839cded9fe413d134d6b42fd98502f71fb Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Tue, 7 Jul 2026 08:49:55 -0300 Subject: [PATCH 36/55] [#49]: Keep recording paused across app backgrounding expo-audio's native module auto-resumes a recorder it paused on background, which left our JS state machine out of sync and made Resume double-start the recorder (IllegalStateException). Undo the native auto-resume on foreground and guard resume() against an already-running recorder so takes reliably continue where they left off. --- src/hooks/useRecorder.test.ts | 68 +++++++++++++++++++++++++++++++++++ src/hooks/useRecorder.ts | 39 ++++++++++++++++---- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/hooks/useRecorder.test.ts b/src/hooks/useRecorder.test.ts index 8fcb0c8..f127f2b 100644 --- a/src/hooks/useRecorder.test.ts +++ b/src/hooks/useRecorder.test.ts @@ -14,6 +14,7 @@ interface FakeTake { const mockRecorder = { currentTime: 0, uri: 'file:///tmp/take-1.m4a', + isRecording: false, record: jest.fn(), pause: jest.fn(), stop: jest.fn().mockResolvedValue(undefined), @@ -86,6 +87,7 @@ describe('useRecorder', () => { jest.clearAllMocks(); mockRecorder.currentTime = 0; mockRecorder.uri = 'file:///tmp/take-1.m4a'; + mockRecorder.isRecording = false; mockPlayer.currentTime = 0; mockPlayer.duration = 0; mockPlayerStatus = { playing: false, didJustFinish: false }; @@ -513,6 +515,72 @@ describe('useRecorder', () => { addSpy.mockRestore(); }); + it('undoes the native auto-resume when returning to the foreground', async () => { + const listeners: Array<(state: string) => void> = []; + const addSpy = jest + .spyOn(AppState, 'addEventListener') + .mockImplementation(((_event: string, cb: (state: string) => void) => { + listeners.push(cb); + return { remove: jest.fn() }; + }) as unknown as typeof AppState.addEventListener); + + const adapter = makeAdapter(); + const { result } = renderHook(() => useRecorder(adapter)); + await waitReady(result); + + await act(async () => { + await result.current.start(); + }); + + await act(async () => { + listeners.forEach(cb => cb('background')); + await Promise.resolve(); + }); + expect(result.current.status).toBe(RecorderStatus.Paused); + + // Simulate expo-audio's native OnActivityEntersForeground auto-resume, + // which flips the native recorder back to recording before our JS handler. + mockRecorder.pause.mockClear(); + mockRecorder.isRecording = true; + + await act(async () => { + listeners.forEach(cb => cb('active')); + await Promise.resolve(); + }); + + // We re-pause the native recorder and keep our state paused. + expect(mockRecorder.pause).toHaveBeenCalledTimes(1); + expect(result.current.status).toBe(RecorderStatus.Paused); + + addSpy.mockRestore(); + }); + + it('resumes without restarting a recorder that is already recording', async () => { + const adapter = makeAdapter(); + const { result } = renderHook(() => useRecorder(adapter)); + await waitReady(result); + + await act(async () => { + await result.current.start(); + }); + await act(async () => { + await result.current.pause(); + }); + expect(result.current.status).toBe(RecorderStatus.Paused); + + // Native recorder is already recording (e.g. auto-resumed on foreground); + // resume() must not call record() again or it would double-start and throw. + mockRecorder.record.mockClear(); + mockRecorder.isRecording = true; + + await act(async () => { + await result.current.resume(); + }); + + expect(mockRecorder.record).not.toHaveBeenCalled(); + expect(result.current.status).toBe(RecorderStatus.Recording); + }); + it('captures recordings into the durable document directory', async () => { const { result } = renderHook(() => useRecorder(makeAdapter())); await waitReady(result); diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts index 28743dc..c008f24 100644 --- a/src/hooks/useRecorder.ts +++ b/src/hooks/useRecorder.ts @@ -268,22 +268,40 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { }; }, []); - // Auto-pause when the app is backgrounded mid-recording. + // Auto-pause when the app is backgrounded mid-recording, and undo + // expo-audio's native auto-resume when we return to the foreground. useEffect(() => { const subscription = AppState.addEventListener( 'change', (nextState: AppStateStatus) => { - if (nextState !== 'active' && status === RecorderStatus.Recording) { - pauseInternal().catch(error => - log.error('Auto-pause on background failed', { error }), - ); + if (nextState !== 'active') { + if (status === RecorderStatus.Recording) { + pauseInternal().catch(error => + log.error('Auto-pause on background failed', { error }), + ); + } + return; + } + + // expo-audio's native module auto-resumes a recorder it paused on + // background (OnActivityEntersForeground -> recorder.record()). Our + // state machine owns pause/resume, so if we come back to the foreground + // while still paused with a live session, undo that auto-resume and stay + // paused until the user explicitly resumes. Reading `isRecording` before + // pausing keeps this idempotent (a paused recorder reports false). + if ( + status === RecorderStatus.Paused && + liveSessionTokenRef.current !== null && + recorder.isRecording + ) { + recorder.pause(); } }, ); return () => subscription.remove(); // pauseInternal is stable via closure and eslint-disable to avoid re-subscribing every tick. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status]); + }, [status, recorder]); useEffect(() => () => clearTick(), [clearTick]); @@ -368,7 +386,14 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { liveSessionTokenRef.current === null ) return; - recorder.record(); + // Guard against the native recorder already running: if the app was + // backgrounded and expo-audio auto-resumed it (and our foreground re-pause + // was missed due to lifecycle ordering), calling record() again would + // start an already-started recorder and throw. Only resume the native + // session when it is actually paused. + if (!recorder.isRecording) { + recorder.record(); + } runningSinceRef.current = Date.now(); setStatus(RecorderStatus.Recording); setCanResume(false); From 24de04f1a78ca80658e563ad23b7078776724834 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Tue, 7 Jul 2026 10:40:57 -0300 Subject: [PATCH 37/55] [#49]: Make partial recordings resilient to app kills Record verse drafts as ADTS AAC and model a take as an ordered list of segments so an in-progress recording survives a process kill. A .m4a partial is unplayable until stop() writes its moov atom; ADTS is self-framing, so a killed segment stays playable and segments merge by byte append. On relaunch the persisted marker rehydrates the take as a recoverable, resumable Paused state: Resume opens a new appended segment preserving elapsed time, Stop commits the existing segments straight from the recovery prompt, and Discard unlinks every segment. On commit the segments are concatenated, moved into durable storage, and the raw parts unlinked. The paused marker stores a segments list (with legacy single-fileUri coercion). Refs #49 --- src/app/tabs/drafting/record/RecordTab.tsx | 1 + .../components/RecordingControls.test.tsx | 30 ++- .../record/components/RecordingControls.tsx | 39 +++- .../record/hooks/useVerseRecorder.test.ts | 86 +++++++- .../drafting/record/hooks/useVerseRecorder.ts | 32 ++- src/hooks/useRecorder.test.ts | 113 ++++++++-- src/hooks/useRecorder.ts | 205 +++++++++++++----- src/services/recordingStorage.test.ts | 49 +++++ src/services/recordingStorage.ts | 31 +++ src/services/storage.test.ts | 32 ++- src/services/storage.ts | 47 +++- 11 files changed, 562 insertions(+), 103 deletions(-) diff --git a/src/app/tabs/drafting/record/RecordTab.tsx b/src/app/tabs/drafting/record/RecordTab.tsx index cbfb7c9..e5f2f64 100644 --- a/src/app/tabs/drafting/record/RecordTab.tsx +++ b/src/app/tabs/drafting/record/RecordTab.tsx @@ -234,6 +234,7 @@ export function RecordTab({ elapsedMs={recorder.elapsedMs} isPlaying={recorder.isPlaying} canResume={recorder.canResume} + isRecovered={recorder.isRecovered} onStart={handleStartPress} onPause={handlePausePress} onResume={handleResumePress} diff --git a/src/app/tabs/drafting/record/components/RecordingControls.test.tsx b/src/app/tabs/drafting/record/components/RecordingControls.test.tsx index 6f3f40b..5d7ec43 100644 --- a/src/app/tabs/drafting/record/components/RecordingControls.test.tsx +++ b/src/app/tabs/drafting/record/components/RecordingControls.test.tsx @@ -100,7 +100,35 @@ describe('RecordingControls', () => { expect(screen.queryByTestId('record-discard-button')).toBeNull(); }); - it('shows discard instead of resume for a rehydrated paused take', () => { + it('offers resume, stop and discard for a recovered paused take', () => { + render( + , + ); + + expect(screen.getByTestId('record-resume-button')).toBeTruthy(); + expect(screen.getByTestId('record-stop-button')).toBeTruthy(); + expect(screen.getByTestId('record-discard-button')).toBeTruthy(); + expect(screen.getByTestId('record-tip')).toHaveTextContent( + 'Recovered a paused take from a previous session. Resume to keep recording, or discard it.', + ); + }); + + it('renders the legacy discard-only layout when a paused take cannot be resumed', () => { render( void; onPause: () => void; onResume: () => void; @@ -36,6 +52,7 @@ export function RecordingControls({ elapsedMs, isPlaying, canResume = true, + isRecovered = false, onStart, onPause, onResume, @@ -172,10 +189,24 @@ export function RecordingControls({ )} + {canResume && isRecovered && onDiscard ? ( + + + Discard take + + ) : null} - {canResume - ? 'Recording paused — review the source below, then resume.' - : 'A paused take was recovered from a previous session. Discard it to start fresh.'} + {pausedTip(canResume, isRecovered)} )} diff --git a/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts b/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts index df729ac..9ac0d5f 100644 --- a/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts +++ b/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts @@ -5,7 +5,7 @@ import { RecorderStatus } from '../../../../../types/recording/types'; const mockRecorder = { currentTime: 0, - uri: 'file:///tmp/take-1.m4a', + uri: 'file:///tmp/take-1.aac', record: jest.fn(), pause: jest.fn(), stop: jest.fn().mockResolvedValue(undefined), @@ -65,14 +65,15 @@ jest.mock('../../../../../db/queries', () => ({ mockGetLatestRecordingForVerse(id), })); -const MOVED_KEY = 'recordings/u/p0/UNK/c000/v000/test-uuid-1.m4a'; +const MOVED_KEY = 'recordings/u/p0/UNK/c000/v000/test-uuid-1.aac'; jest.mock('../../../../../services/recordingStorage', () => ({ buildRecordingKey: (parts: unknown) => { mockBuildRecordingKey(parts); return MOVED_KEY; }, - extensionFromUri: jest.fn(() => 'm4a'), + extensionFromUri: jest.fn(() => 'aac'), + concatenateAacSegments: jest.fn(async (fileUris: string[]) => fileUris[0]), moveIntoStore: jest.fn(async ({ key }: { key: string }) => ({ key, sizeBytes: 1234, @@ -92,6 +93,12 @@ const RESOLVE_MOCK = jest.requireMock( '../../../../../services/recordingStorage', ).resolveRecordingUri as jest.Mock; +const CONCAT_MOCK = jest.requireMock('../../../../../services/recordingStorage') + .concatenateAacSegments as jest.Mock; + +const MOVE_MOCK = jest.requireMock('../../../../../services/recordingStorage') + .moveIntoStore as jest.Mock; + function existingRecording(overrides: Record = {}) { return { id: 'existing', @@ -114,7 +121,7 @@ describe('useVerseRecorder', () => { beforeEach(() => { jest.clearAllMocks(); mockRecorder.currentTime = 0; - mockRecorder.uri = 'file:///tmp/take-1.m4a'; + mockRecorder.uri = 'file:///tmp/take-1.aac'; mockPlayer.currentTime = 0; mockPlayer.duration = 0; mockPlayerStatus = { playing: false, didJustFinish: false }; @@ -172,7 +179,7 @@ describe('useVerseRecorder', () => { expect(mockSetPausedTake).toHaveBeenCalledWith( expect.objectContaining({ bibleTextId: 42, - fileUri: 'file:///tmp/take-1.m4a', + segments: ['file:///tmp/take-1.aac'], elapsedMs: 2500, sessionToken: expect.any(String), }), @@ -262,6 +269,59 @@ describe('useVerseRecorder', () => { } }); + it('merges segments and unlinks the raw parts when committing a resumed take', async () => { + jest.useFakeTimers(); + try { + mockGetPausedTake.mockReturnValue({ + bibleTextId: 42, + segments: ['file:///docs/seg-0.aac'], + elapsedMs: 4500, + startedAt: '2026-07-01T00:00:00.000Z', + sessionToken: 'stale-token', + }); + // A genuine multi-segment merge produces a distinct merged file. + CONCAT_MOCK.mockResolvedValueOnce('file:///docs/merged.aac'); + + const { result } = renderHook(() => + useVerseRecorder({ bibleTextId: 42 }), + ); + await waitReady(result); + expect(result.current.status).toBe(RecorderStatus.Paused); + + mockRecorder.uri = 'file:///docs/seg-1.aac'; + jest.setSystemTime(new Date('2026-07-01T00:00:00.000Z')); + await act(async () => { + await result.current.resume(); + }); + + jest.setSystemTime(new Date('2026-07-01T00:00:01.000Z')); + await act(async () => { + await result.current.stop(); + }); + + expect(CONCAT_MOCK).toHaveBeenCalledWith([ + 'file:///docs/seg-0.aac', + 'file:///docs/seg-1.aac', + ]); + expect(MOVE_MOCK).toHaveBeenCalledWith( + expect.objectContaining({ sourceUri: 'file:///docs/merged.aac' }), + ); + // Both raw segments are unlinked; the merged file was moved into the store. + expect(mockDeleteRecordingFile).toHaveBeenCalledWith( + 'file:///docs/seg-0.aac', + ); + expect(mockDeleteRecordingFile).toHaveBeenCalledWith( + 'file:///docs/seg-1.aac', + ); + expect(mockInsertRecording).toHaveBeenCalledWith( + expect.objectContaining({ localFilePath: MOVED_KEY, durationMs: 5500 }), + ); + expect(result.current.status).toBe(RecorderStatus.Review); + } finally { + jest.useRealTimers(); + } + }); + it('deletes the current take via the repository', async () => { mockGetLatestRecordingForVerse.mockResolvedValueOnce(existingRecording()); const { result } = renderHook(() => useVerseRecorder({ bibleTextId: 42 })); @@ -276,10 +336,13 @@ describe('useVerseRecorder', () => { expect(result.current.currentRecording).toBeNull(); }); - it('unlinks the durable partial file when a paused take is discarded', async () => { + it('unlinks every partial segment when a recovered paused take is discarded', async () => { mockGetPausedTake.mockReturnValue({ bibleTextId: 42, - fileUri: 'file:///docs/partial-take.m4a', + segments: [ + 'file:///docs/partial-take-0.aac', + 'file:///docs/partial-take-1.aac', + ], elapsedMs: 4500, startedAt: '2026-07-01T00:00:00.000Z', sessionToken: 'stale-token', @@ -288,14 +351,19 @@ describe('useVerseRecorder', () => { const { result } = renderHook(() => useVerseRecorder({ bibleTextId: 42 })); await waitReady(result); expect(result.current.status).toBe(RecorderStatus.Paused); - expect(result.current.canResume).toBe(false); + // A rehydrated take is now resumable and flagged as recovered. + expect(result.current.canResume).toBe(true); + expect(result.current.isRecovered).toBe(true); await act(async () => { await result.current.discardPaused(); }); expect(mockDeleteRecordingFile).toHaveBeenCalledWith( - 'file:///docs/partial-take.m4a', + 'file:///docs/partial-take-0.aac', + ); + expect(mockDeleteRecordingFile).toHaveBeenCalledWith( + 'file:///docs/partial-take-1.aac', ); expect(mockClearPausedTake).toHaveBeenCalledWith(42); expect(result.current.status).toBe(RecorderStatus.Idle); diff --git a/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts b/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts index dd050f9..2b308ca 100644 --- a/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts +++ b/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts @@ -12,6 +12,7 @@ import { } from '../../../../../services/storage'; import { buildRecordingKey, + concatenateAacSegments, deleteRecordingFile, extensionFromUri, moveIntoStore, @@ -79,11 +80,28 @@ export function useVerseRecorder({ clearPausedTake(bibleTextId); }, - deletePausedFile: fileUri => deleteRecordingFile(fileUri), + deletePausedFiles: fileUris => + fileUris.forEach(fileUri => deleteRecordingFile(fileUri)), - onCommit: async ({ fileUri, durationMs }) => { + onCommit: async ({ fileUris, durationMs }) => { if (bibleTextId === null) return null; + // Merge the take's segments into a single file. For a one-segment take + // this returns that file unchanged (moveIntoStore relocates it below); + // for a multi-segment take (resumed across a kill) it produces a new + // merged file, leaving the raw segments to be unlinked afterwards. + let mergedUri: string; + try { + mergedUri = await concatenateAacSegments(fileUris); + } catch (error) { + log.error('Failed to concatenate recording segments', { + bibleTextId, + segments: fileUris.length, + error, + }); + return null; + } + const recordingId = randomUUID(); const key = buildRecordingKey({ userId: userId ?? '', @@ -92,14 +110,14 @@ export function useVerseRecorder({ chapterNumber: chapterNumber ?? 0, verseNumber: verseNumber ?? 0, recordingId, - extension: extensionFromUri(fileUri), + extension: extensionFromUri(mergedUri), }); // Move the take out of the evictable cache before recording it in the // DB so a row never points at a file the OS could reclaim. let moved; try { - moved = await moveIntoStore({ sourceUri: fileUri, key }); + moved = await moveIntoStore({ sourceUri: mergedUri, key }); } catch (error) { log.error('Failed to move recording into durable store', { bibleTextId, @@ -108,6 +126,12 @@ export function useVerseRecorder({ return null; } + // Unlink any raw segments not consumed by the move (i.e. everything but + // a single-segment take, whose only file was the one just moved). + fileUris + .filter(fileUri => fileUri !== mergedUri) + .forEach(fileUri => deleteRecordingFile(fileUri)); + try { return await insertRecording({ id: recordingId, diff --git a/src/hooks/useRecorder.test.ts b/src/hooks/useRecorder.test.ts index f127f2b..86d947b 100644 --- a/src/hooks/useRecorder.test.ts +++ b/src/hooks/useRecorder.test.ts @@ -13,7 +13,7 @@ interface FakeTake { const mockRecorder = { currentTime: 0, - uri: 'file:///tmp/take-1.m4a', + uri: 'file:///tmp/take-1.aac', isRecording: false, record: jest.fn(), pause: jest.fn(), @@ -73,7 +73,7 @@ function makeAdapter( loadPaused: jest.fn().mockReturnValue(null), persistPaused: jest.fn(), clearPaused: jest.fn(), - deletePausedFile: jest.fn(), + deletePausedFiles: jest.fn(), ...overrides, }; } @@ -86,7 +86,7 @@ describe('useRecorder', () => { beforeEach(() => { jest.clearAllMocks(); mockRecorder.currentTime = 0; - mockRecorder.uri = 'file:///tmp/take-1.m4a'; + mockRecorder.uri = 'file:///tmp/take-1.aac'; mockRecorder.isRecording = false; mockPlayer.currentTime = 0; mockPlayer.duration = 0; @@ -132,10 +132,10 @@ describe('useRecorder', () => { expect(result.current.currentRecording?.id).toBe('existing'); }); - it('surfaces a persisted paused-take marker on mount', async () => { + it('surfaces a persisted paused-take marker on mount as a recoverable, resumable take', async () => { const adapter = makeAdapter({ loadPaused: jest.fn().mockReturnValue({ - fileUri: '/tmp/paused.m4a', + segments: ['/tmp/paused-0.aac'], elapsedMs: 4500, startedAt: '2026-07-01T00:00:00.000Z', sessionToken: 'stale-token', @@ -145,13 +145,14 @@ describe('useRecorder', () => { await waitReady(result); expect(result.current.status).toBe(RecorderStatus.Paused); expect(result.current.elapsedMs).toBe(4500); - expect(result.current.canResume).toBe(false); + expect(result.current.canResume).toBe(true); + expect(result.current.isRecovered).toBe(true); }); - it('does not resume a rehydrated paused take without a live native session', async () => { + it('resumes a rehydrated paused take by opening a new appended segment', async () => { const adapter = makeAdapter({ loadPaused: jest.fn().mockReturnValue({ - fileUri: '/tmp/paused.m4a', + segments: ['/tmp/paused-0.aac'], elapsedMs: 4500, startedAt: '2026-07-01T00:00:00.000Z', sessionToken: 'stale-token', @@ -160,12 +161,82 @@ describe('useRecorder', () => { const { result } = renderHook(() => useRecorder(adapter)); await waitReady(result); + // The previous native recorder died with the process, so resume must open a + // fresh session (prepare + record) rather than resume a native session. await act(async () => { await result.current.resume(); }); - expect(mockRecorder.record).not.toHaveBeenCalled(); + expect(mockRecorder.prepareToRecordAsync).toHaveBeenCalled(); + expect(mockRecorder.record).toHaveBeenCalled(); + expect(result.current.status).toBe(RecorderStatus.Recording); + // Elapsed time is preserved across the kill (rehydrated base of 4.5s). + expect(result.current.elapsedMs).toBe(4500); + expect(result.current.isRecovered).toBe(false); + }); + + it('commits a rehydrated take when Stop is pressed straight from the recovery prompt', async () => { + const adapter = makeAdapter({ + loadPaused: jest.fn().mockReturnValue({ + segments: ['/tmp/paused-0.aac', '/tmp/paused-1.aac'], + elapsedMs: 7200, + startedAt: '2026-07-01T00:00:00.000Z', + sessionToken: 'stale-token', + }), + }); + const { result } = renderHook(() => useRecorder(adapter)); + await waitReady(result); expect(result.current.status).toBe(RecorderStatus.Paused); + + await act(async () => { + await result.current.stop(); + }); + + // No live native recorder, so we must not call stop() on it, but we still + // commit the persisted segments with the rehydrated elapsed time. + expect(mockRecorder.stop).not.toHaveBeenCalled(); + expect(adapter.onCommit).toHaveBeenCalledWith({ + fileUris: ['/tmp/paused-0.aac', '/tmp/paused-1.aac'], + durationMs: 7200, + }); + expect(adapter.clearPaused).toHaveBeenCalled(); + expect(result.current.status).toBe(RecorderStatus.Review); + }); + + it('commits every segment of a take resumed after a kill', async () => { + jest.useFakeTimers(); + try { + const adapter = makeAdapter({ + loadPaused: jest.fn().mockReturnValue({ + segments: ['/tmp/paused-0.aac'], + elapsedMs: 4500, + startedAt: '2026-07-01T00:00:00.000Z', + sessionToken: 'stale-token', + }), + }); + const { result } = renderHook(() => useRecorder(adapter)); + await waitReady(result); + + // The new session records into a distinct segment file. + mockRecorder.uri = 'file:///tmp/paused-1.aac'; + jest.setSystemTime(new Date('2026-07-01T00:00:00.000Z')); + await act(async () => { + await result.current.resume(); + }); + + jest.setSystemTime(new Date('2026-07-01T00:00:01.000Z')); + await act(async () => { + await result.current.stop(); + }); + + expect(adapter.onCommit).toHaveBeenCalledWith({ + fileUris: ['/tmp/paused-0.aac', 'file:///tmp/paused-1.aac'], + durationMs: 5500, + }); + expect(result.current.status).toBe(RecorderStatus.Review); + } finally { + jest.useRealTimers(); + } }); it('transitions idle -> recording on start', async () => { @@ -204,7 +275,7 @@ describe('useRecorder', () => { expect(mockRecorder.pause).toHaveBeenCalled(); expect(adapter.persistPaused).toHaveBeenCalledWith( expect.objectContaining({ - fileUri: 'file:///tmp/take-1.m4a', + segments: ['file:///tmp/take-1.aac'], elapsedMs: 2500, sessionToken: expect.any(String), }), @@ -229,7 +300,7 @@ describe('useRecorder', () => { expect(mockRecorder.stop).toHaveBeenCalled(); expect(adapter.onCommit).toHaveBeenCalledWith({ - fileUri: 'file:///tmp/take-1.m4a', + fileUris: ['file:///tmp/take-1.aac'], durationMs: 5500, }); expect(adapter.clearPaused).toHaveBeenCalled(); @@ -604,17 +675,20 @@ describe('useRecorder', () => { await result.current.discardPaused(); }); - expect(adapter.deletePausedFile).toHaveBeenCalledWith( - 'file:///tmp/take-1.m4a', - ); + expect(adapter.deletePausedFiles).toHaveBeenCalledWith([ + 'file:///tmp/take-1.aac', + ]); expect(adapter.clearPaused).toHaveBeenCalled(); expect(result.current.status).toBe(RecorderStatus.Idle); }); - it('unlinks the durable partial file when a paused take is discarded', async () => { + it('unlinks every partial segment when a paused take is discarded', async () => { const adapter = makeAdapter({ loadPaused: jest.fn().mockReturnValue({ - fileUri: 'file:///docs/partial-take.m4a', + segments: [ + 'file:///docs/partial-take-0.aac', + 'file:///docs/partial-take-1.aac', + ], elapsedMs: 4500, startedAt: '2026-07-01T00:00:00.000Z', sessionToken: 'stale-token', @@ -629,9 +703,10 @@ describe('useRecorder', () => { await result.current.discardPaused(); }); - expect(adapter.deletePausedFile).toHaveBeenCalledWith( - 'file:///docs/partial-take.m4a', - ); + expect(adapter.deletePausedFiles).toHaveBeenCalledWith([ + 'file:///docs/partial-take-0.aac', + 'file:///docs/partial-take-1.aac', + ]); expect(adapter.clearPaused).toHaveBeenCalled(); expect(result.current.status).toBe(RecorderStatus.Idle); }); diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts index c008f24..371cb4d 100644 --- a/src/hooks/useRecorder.ts +++ b/src/hooks/useRecorder.ts @@ -7,7 +7,6 @@ import { setAudioModeAsync, useAudioRecorder, } from 'expo-audio'; -import type { AudioRecorder } from 'expo-audio'; import { logger } from '../utils/logger'; import { useDraftPlayback } from './useDraftPlayback'; @@ -34,12 +33,16 @@ export interface PermissionRequestResult { * A partial take captured before the recorder was paused (manually or by * backgrounding). Persisted by the adapter so an in-flight take survives a * process kill; the `startedAt`/`elapsedMs` pair rehydrates the timer on mount. + * + * `segments` is the ordered list of audio files that make up the take — one per + * app-lifetime recording session. A live session is a single segment; resuming + * after a process kill appends a new segment. They are concatenated on stop. */ export interface PausedTakeState { - fileUri: string; + segments: string[]; elapsedMs: number; startedAt: string; - /** Present on markers written during a live session; absent on legacy markers. */ + /** Present on markers written during a live session; absent once rehydrated. */ sessionToken?: string; } @@ -63,7 +66,7 @@ export interface RecorderAdapter { * state). Throwing is also treated as a recoverable failure. */ onCommit: (args: { - fileUri: string; + fileUris: string[]; durationMs: number; }) => Promise; /** Delete a committed take (row + file). */ @@ -76,8 +79,8 @@ export interface RecorderAdapter { persistPaused: (state: PausedTakeState) => void; /** Clear the paused-take marker for the current session. */ clearPaused: () => void; - /** Best-effort unlink of a paused partial file being discarded. */ - deletePausedFile: (fileUri: string) => void; + /** Best-effort unlink of the partial segment files being discarded. */ + deletePausedFiles: (fileUris: string[]) => void; } export interface UseRecorderApi { @@ -86,8 +89,14 @@ export interface UseRecorderApi { permission: PermissionState; currentRecording: T | null; isReady: boolean; - /** False when a paused take was rehydrated from storage (no live native session). */ + /** True while a paused take can be resumed (always true once paused). */ canResume: boolean; + /** + * True when the current paused take was rehydrated from storage after a + * process kill (no live native session). Resuming opens a new segment; the UI + * should also offer Discard. + */ + isRecovered: boolean; isPlaying: boolean; requestPermission: () => Promise; @@ -140,8 +149,16 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { // Capture straight into the durable document directory (not the evictable // cache) so paused/backgrounded partial takes survive until we move or delete // them. Committed takes are relocated into the organized tree on Stop. + // + // Record as ADTS AAC (`.aac`) rather than the preset's `.m4a`: an MP4/M4A file + // is only valid once its `moov` atom is written on a clean stop(), so a + // process kill leaves it unplayable and un-appendable. ADTS is a self-framing + // stream — a killed segment stays playable and multiple segments concatenate + // by byte append, which is what makes resume-after-kill possible. const recorder = useAudioRecorder({ ...RecordingPresets.HIGH_QUALITY, + extension: '.aac', + android: { outputFormat: 'aac_adts', audioEncoder: 'aac' }, directory: 'document', }); @@ -151,6 +168,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { const [currentRecording, setCurrentRecording] = useState(null); const [isReady, setIsReady] = useState(false); const [canResume, setCanResume] = useState(false); + const [isRecovered, setIsRecovered] = useState(false); // The adapter is read via a ref inside callbacks/effects so they always see // the latest injected closures without being re-created (and re-subscribing) @@ -180,6 +198,9 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { const baseElapsedRef = useRef(0); const runningSinceRef = useRef(null); const liveSessionTokenRef = useRef(null); + // Ordered audio files that make up the in-flight take (one per app-lifetime + // recording session). Concatenated on stop; unlinked on discard. + const segmentsRef = useRef([]); const sessionKeyRef = useRef(sessionKey); sessionKeyRef.current = sessionKey; @@ -207,11 +228,13 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { async function load() { setIsReady(false); setCanResume(false); + setIsRecovered(false); setElapsedMs(0); baseElapsedRef.current = 0; runningSinceRef.current = null; startedAtRef.current = null; liveSessionTokenRef.current = null; + segmentsRef.current = []; clearTick(); if (sessionKey === null) { @@ -231,10 +254,14 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { setCurrentRecording(latest); if (paused) { - // Restored markers have no live native recorder — resume is blocked; - // the UI should steer the user toward discardPaused instead. + // Restored markers have no live native recorder session, but the take + // is still resumable: resume() records a NEW segment appended to the + // rehydrated ones. `liveSessionTokenRef` stays null so resume() knows to + // open a fresh session rather than resume the (nonexistent) native one. liveSessionTokenRef.current = null; - setCanResume(false); + segmentsRef.current = paused.segments; + setCanResume(true); + setIsRecovered(true); setStatus(RecorderStatus.Paused); setElapsedMs(paused.elapsedMs); baseElapsedRef.current = paused.elapsedMs; @@ -305,23 +332,34 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { useEffect(() => () => clearTick(), [clearTick]); - const persistPausedMarker = useCallback( - (rec: AudioRecorder, elapsed: number) => { - const currentSessionKey = sessionKeyRef.current; - const sessionToken = liveSessionTokenRef.current; - if (currentSessionKey === null || !rec.uri || sessionToken === null) - return; - const startedAt = startedAtRef.current ?? new Date().toISOString(); - startedAtRef.current = startedAt; - adapterRef.current.persistPaused({ - fileUri: rec.uri, - elapsedMs: elapsed, - startedAt, - sessionToken, - }); - }, - [], - ); + // Append the recorder's current output file to the segment list. Called after + // starting/continuing a session; `includes` keeps it idempotent if the uri + // was already registered (e.g. re-read after stop()). + const registerCurrentSegment = useCallback(() => { + const uri = recorder.uri; + if (uri && !segmentsRef.current.includes(uri)) { + segmentsRef.current = [...segmentsRef.current, uri]; + } + }, [recorder]); + + const persistPausedMarker = useCallback((elapsed: number) => { + const currentSessionKey = sessionKeyRef.current; + const sessionToken = liveSessionTokenRef.current; + if ( + currentSessionKey === null || + sessionToken === null || + segmentsRef.current.length === 0 + ) + return; + const startedAt = startedAtRef.current ?? new Date().toISOString(); + startedAtRef.current = startedAt; + adapterRef.current.persistPaused({ + segments: segmentsRef.current, + elapsedMs: elapsed, + startedAt, + sessionToken, + }); + }, []); const requestPermission = useCallback(async (): Promise => { @@ -333,19 +371,39 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { }; }, []); + // Fresh take: reset elapsed and the segment list, then open the first segment. const startRecordingSession = useCallback(async () => { await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true }); await recorder.prepareToRecordAsync(); liveSessionTokenRef.current = createLiveSessionToken(); + segmentsRef.current = []; setCanResume(false); + setIsRecovered(false); baseElapsedRef.current = 0; runningSinceRef.current = Date.now(); startedAtRef.current = new Date().toISOString(); setElapsedMs(0); recorder.record(); + registerCurrentSegment(); + setStatus(RecorderStatus.Recording); + startTicking(); + }, [recorder, registerCurrentSegment, startTicking]); + + // Resume a take rehydrated after a process kill: open a NEW segment appended + // to the restored ones, preserving the accumulated elapsed time and started-at + // so the take reads as one continuous recording. + const continueRecordingSession = useCallback(async () => { + await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true }); + await recorder.prepareToRecordAsync(); + liveSessionTokenRef.current = createLiveSessionToken(); + runningSinceRef.current = Date.now(); + recorder.record(); + registerCurrentSegment(); + setCanResume(false); + setIsRecovered(false); setStatus(RecorderStatus.Recording); startTicking(); - }, [recorder, startTicking]); + }, [recorder, registerCurrentSegment, startTicking]); const start = useCallback(async () => { const currentSessionKey = sessionKeyRef.current; @@ -373,7 +431,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { setElapsedMs(finalElapsed); setStatus(RecorderStatus.Paused); setCanResume(liveSessionTokenRef.current !== null); - persistPausedMarker(recorder, finalElapsed); + persistPausedMarker(finalElapsed); }, [clearTick, persistPausedMarker, recorder, status]); const pause = useCallback(async () => { @@ -381,16 +439,21 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { }, [pauseInternal]); const resume = useCallback(async () => { - if ( - status !== RecorderStatus.Paused || - liveSessionTokenRef.current === null - ) + if (status !== RecorderStatus.Paused) return; + + // Rehydrated take (no live native session): the previous recorder died with + // the process, so open a new segment instead of resuming a native session. + if (liveSessionTokenRef.current === null) { + if (segmentsRef.current.length === 0) return; + await continueRecordingSession(); return; - // Guard against the native recorder already running: if the app was - // backgrounded and expo-audio auto-resumed it (and our foreground re-pause - // was missed due to lifecycle ordering), calling record() again would - // start an already-started recorder and throw. Only resume the native - // session when it is actually paused. + } + + // Live session: the native recorder still holds the current segment file. + // Guard against it already running: if the app was backgrounded and + // expo-audio auto-resumed it (and our foreground re-pause was missed due to + // lifecycle ordering), calling record() again would start an already-started + // recorder and throw. Only resume the native session when it is paused. if (!recorder.isRecording) { recorder.record(); } @@ -398,7 +461,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { setStatus(RecorderStatus.Recording); setCanResume(false); startTicking(); - }, [recorder, startTicking, status]); + }, [continueRecordingSession, recorder, startTicking, status]); const commitRecording = useCallback(async () => { const currentSessionKey = sessionKeyRef.current; @@ -411,22 +474,36 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { segmentStart === null ? 0 : Date.now() - segmentStart; const duration = baseElapsedRef.current + activeSegmentMs; - try { - await recorder.stop(); - } catch (error) { - log.warn('Recorder stop failed while committing take', { - sessionKey: currentSessionKey, - error, - }); - setStatus(currentRecording ? RecorderStatus.Review : RecorderStatus.Idle); - return; - } finally { + // A take rehydrated after a process kill (Stop pressed straight from the + // recovery prompt) has no live native recorder — its segments are already + // finalized on disk. Only stop/finalize the recorder when a session is + // actually live; otherwise committing the persisted segments is enough. + const hasLiveSession = liveSessionTokenRef.current !== null; + if (hasLiveSession) { + try { + await recorder.stop(); + } catch (error) { + log.warn('Recorder stop failed while committing take', { + sessionKey: currentSessionKey, + error, + }); + setStatus( + currentRecording ? RecorderStatus.Review : RecorderStatus.Idle, + ); + return; + } finally { + clearTick(); + } + // Capture the just-finalized segment in case its uri wasn't ready when + // the session opened, then commit the full ordered segment list. + registerCurrentSegment(); + } else { clearTick(); } - const fileUri = recorder.uri; - if (!fileUri) { - log.error('Recorder returned no URI on stop; skipping commit'); + const fileUris = segmentsRef.current; + if (fileUris.length === 0) { + log.error('Recorder produced no segments on stop; skipping commit'); setStatus(currentRecording ? RecorderStatus.Review : RecorderStatus.Idle); return; } @@ -434,7 +511,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { let committed: T | null; try { committed = await adapterRef.current.onCommit({ - fileUri, + fileUris, durationMs: duration, }); } catch (error) { @@ -452,22 +529,28 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { } adapterRef.current.clearPaused(); + segmentsRef.current = []; baseElapsedRef.current = 0; runningSinceRef.current = null; startedAtRef.current = null; liveSessionTokenRef.current = null; setCanResume(false); + setIsRecovered(false); setElapsedMs(duration); setCurrentRecording(committed); setStatus(RecorderStatus.Review); - }, [clearTick, currentRecording, recorder]); + }, [clearTick, currentRecording, recorder, registerCurrentSegment]); const stop = useCallback(async () => { if (status !== RecorderStatus.Recording && status !== RecorderStatus.Paused) return; + // A rehydrated paused take can be committed straight from the recovery + // prompt as long as it still has segments on disk; only bail when there is + // nothing to finalize. if ( status === RecorderStatus.Paused && - liveSessionTokenRef.current === null + liveSessionTokenRef.current === null && + segmentsRef.current.length === 0 ) return; await commitRecording(); @@ -512,7 +595,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { const discardPaused = useCallback(async () => { const currentSessionKey = sessionKeyRef.current; if (currentSessionKey === null) return; - // Read the marker before clearing so we can unlink the durable partial file + // Read the marker before clearing so we can unlink every partial segment // (recorder.uri is unreliable after a process kill; the marker is not). const marker = adapterRef.current.loadPaused(currentSessionKey); try { @@ -522,18 +605,21 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { error, }); } - const fileUri = marker?.fileUri ?? recorder.uri; - if (fileUri) adapterRef.current.deletePausedFile(fileUri); + registerCurrentSegment(); + const fileUris = marker?.segments ?? segmentsRef.current; + if (fileUris.length > 0) adapterRef.current.deletePausedFiles(fileUris); adapterRef.current.clearPaused(); clearTick(); + segmentsRef.current = []; baseElapsedRef.current = 0; runningSinceRef.current = null; startedAtRef.current = null; liveSessionTokenRef.current = null; setCanResume(false); + setIsRecovered(false); setElapsedMs(0); setStatus(currentRecording ? RecorderStatus.Review : RecorderStatus.Idle); - }, [clearTick, currentRecording, recorder]); + }, [clearTick, currentRecording, recorder, registerCurrentSegment]); const togglePlayback = useCallback(async () => { if (status !== RecorderStatus.Review || !currentRecording) return; @@ -547,6 +633,7 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { currentRecording, isReady, canResume, + isRecovered, isPlaying, requestPermission, start, diff --git a/src/services/recordingStorage.test.ts b/src/services/recordingStorage.test.ts index c1acd5e..1ee6c95 100644 --- a/src/services/recordingStorage.test.ts +++ b/src/services/recordingStorage.test.ts @@ -1,5 +1,6 @@ import { buildRecordingKey, + concatenateAacSegments, deleteRecordingFile, extensionFromUri, moveIntoStore, @@ -11,9 +12,16 @@ const mockDocument = { uri: 'file:///docs' }; const mockMove = jest.fn().mockResolvedValue(undefined); const mockCreate = jest.fn(); const mockDelete = jest.fn(); +const mockCreateFile = jest.fn(); +const mockWrite = jest.fn(); +const mockBytes = jest.fn(async () => new Uint8Array([1, 2, 3])); let mockFileExists = true; let mockFileSize = 4096; +jest.mock('expo-crypto', () => ({ + randomUUID: jest.fn(() => 'merge-uuid'), +})); + jest.mock('expo-file-system', () => ({ Paths: { get document() { @@ -26,6 +34,9 @@ jest.mock('expo-file-system', () => ({ uri: uris.map(u => (typeof u === 'string' ? u : u.uri)).join('/'), move: mockMove, delete: mockDelete, + create: mockCreateFile, + write: mockWrite, + bytes: mockBytes, get exists() { return mockFileExists; }, @@ -175,6 +186,44 @@ describe('moveIntoStore', () => { }); }); +describe('concatenateAacSegments', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws when given no segments', async () => { + await expect(concatenateAacSegments([])).rejects.toThrow( + 'at least one segment', + ); + }); + + it('returns a single segment unchanged without touching the filesystem', async () => { + const result = await concatenateAacSegments(['file:///docs/only.aac']); + + expect(result).toBe('file:///docs/only.aac'); + expect(mockCreateFile).not.toHaveBeenCalled(); + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it('byte-appends multiple segments into a fresh merged file', async () => { + const result = await concatenateAacSegments([ + 'file:///docs/a.aac', + 'file:///docs/b.aac', + ]); + + expect(mockCreateFile).toHaveBeenCalledWith({ + intermediates: true, + overwrite: true, + }); + expect(mockBytes).toHaveBeenCalledTimes(2); + expect(mockWrite).toHaveBeenCalledTimes(2); + expect(mockWrite).toHaveBeenCalledWith(expect.any(Uint8Array), { + append: true, + }); + expect(result).toContain('merge-merge-uuid.aac'); + }); +}); + describe('deleteRecordingFile', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/services/recordingStorage.ts b/src/services/recordingStorage.ts index 0c3524f..fac1351 100644 --- a/src/services/recordingStorage.ts +++ b/src/services/recordingStorage.ts @@ -1,4 +1,5 @@ import { Directory, File, Paths } from 'expo-file-system'; +import { randomUUID } from 'expo-crypto'; import { logger } from '../utils/logger'; const log = logger.create('recordingStorage'); @@ -133,6 +134,36 @@ export async function moveIntoStore({ return { key, sizeBytes: typeof size === 'number' ? size : null }; } +/** + * Concatenates ordered ADTS AAC (`.aac`) segments into a single file. ADTS is a + * self-framing bitstream, so a plain byte append yields a valid, playable file + * — this is what lets a take span multiple recording sessions (including ones + * separated by a process kill). A single segment is returned unchanged (the + * caller moves it into durable storage directly, so no copy is needed). + * + * The merged file is written to the document directory; the caller is + * responsible for moving it into the durable tree and unlinking the raw + * segments afterwards. + */ +export async function concatenateAacSegments( + fileUris: string[], +): Promise { + if (fileUris.length === 0) { + throw new Error('concatenateAacSegments requires at least one segment'); + } + if (fileUris.length === 1) { + return fileUris[0]!; + } + + const merged = new File(Paths.document, `merge-${randomUUID()}.aac`); + merged.create({ intermediates: true, overwrite: true }); + for (const uri of fileUris) { + const bytes = await new File(uri).bytes(); + merged.write(bytes, { append: true }); + } + return merged.uri; +} + /** Best-effort unlink of a stored recording file. Never throws. */ export function deleteRecordingFile(pathOrKey: string): void { try { diff --git a/src/services/storage.test.ts b/src/services/storage.test.ts index 405362c..e3a66c3 100644 --- a/src/services/storage.test.ts +++ b/src/services/storage.test.ts @@ -18,7 +18,7 @@ const mockRemoveItemSync = jest.mocked(Storage).mock.results[0]!.value const validMarker = { bibleTextId: 42, - fileUri: 'file:///docs/partial-take.m4a', + segments: ['file:///docs/partial-take-0.aac'], elapsedMs: 4500, startedAt: '2026-07-01T00:00:00.000Z', }; @@ -36,10 +36,28 @@ describe('getPausedTake', () => { expect(mockGetItemSync).toHaveBeenCalledWith('paused_take:42'); }); + it('coerces a legacy single-fileUri marker into a one-segment list', () => { + mockGetItemSync.mockReturnValue( + JSON.stringify({ + bibleTextId: 42, + fileUri: 'file:///docs/legacy-take.m4a', + elapsedMs: 4500, + startedAt: '2026-07-01T00:00:00.000Z', + }), + ); + + expect(getPausedTake(42)).toEqual({ + bibleTextId: 42, + segments: ['file:///docs/legacy-take.m4a'], + elapsedMs: 4500, + startedAt: '2026-07-01T00:00:00.000Z', + }); + }); + it('returns null when bibleTextId is missing', () => { mockGetItemSync.mockReturnValue( JSON.stringify({ - fileUri: validMarker.fileUri, + segments: validMarker.segments, elapsedMs: validMarker.elapsedMs, startedAt: validMarker.startedAt, }), @@ -68,7 +86,7 @@ describe('getPausedTake', () => { expect(getPausedTake(42)).toBeNull(); }); - it('returns null when fileUri is missing', () => { + it('returns null when neither segments nor a legacy fileUri are present', () => { mockGetItemSync.mockReturnValue( JSON.stringify({ bibleTextId: validMarker.bibleTextId, @@ -80,6 +98,14 @@ describe('getPausedTake', () => { expect(getPausedTake(42)).toBeNull(); }); + it('returns null when segments is an empty array', () => { + mockGetItemSync.mockReturnValue( + JSON.stringify({ ...validMarker, segments: [] }), + ); + + expect(getPausedTake(42)).toBeNull(); + }); + it('returns null for invalid JSON', () => { mockGetItemSync.mockReturnValue('{not json'); diff --git a/src/services/storage.ts b/src/services/storage.ts index 1b6e72b..8ee6b82 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -179,10 +179,15 @@ export function setUserLastSyncedAt(userId: string, timestamp: string) { * Paused-take marker: written when the recorder pauses (manual or via * backgrounding) so the drafting page can detect an in-flight take on the * next mount, even after a process kill. + * + * A take is captured as an ordered list of `segments` (one audio file per + * app-lifetime recording session). A single live session is one segment; + * resuming after a process kill appends a new segment. On stop the segments are + * concatenated into the committed take. */ export interface PausedTakeMarker { bibleTextId: number; - fileUri: string; + segments: string[]; elapsedMs: number; startedAt: string; sessionToken?: string; @@ -192,19 +197,52 @@ function pausedTakeKey(bibleTextId: number): string { return `paused_take:${bibleTextId}`; } +/** + * Accepts the current `segments` array and also coerces a legacy single + * `fileUri` marker into a one-segment list, so a take paused before this change + * still loads. Returns null when neither shape is present/valid. + */ +function normalizeSegments(parsed: { + segments?: unknown; + fileUri?: unknown; +}): string[] | null { + if ( + Array.isArray(parsed.segments) && + parsed.segments.length > 0 && + parsed.segments.every(segment => typeof segment === 'string') + ) { + return parsed.segments as string[]; + } + if (typeof parsed.fileUri === 'string') { + return [parsed.fileUri]; + } + return null; +} + export function getPausedTake(bibleTextId: number): PausedTakeMarker | null { const raw = kvStorage.getItemSync(pausedTakeKey(bibleTextId)); if (!raw) return null; try { - const parsed = JSON.parse(raw) as PausedTakeMarker; + const parsed = JSON.parse(raw) as Partial & { + fileUri?: string; + }; + const segments = normalizeSegments(parsed); if ( Number.isFinite(parsed?.bibleTextId) && parsed.bibleTextId === bibleTextId && - typeof parsed?.fileUri === 'string' && + segments !== null && typeof parsed?.elapsedMs === 'number' && typeof parsed?.startedAt === 'string' ) { - return parsed; + return { + bibleTextId: parsed.bibleTextId, + segments, + elapsedMs: parsed.elapsedMs, + startedAt: parsed.startedAt, + ...(typeof parsed.sessionToken === 'string' + ? { sessionToken: parsed.sessionToken } + : {}), + }; } return null; } catch (error) { @@ -221,6 +259,7 @@ export function setPausedTake(marker: PausedTakeMarker) { log.info('Paused take marker set', { bibleTextId: marker.bibleTextId, elapsedMs: marker.elapsedMs, + segments: marker.segments.length, }); } From 87773b8e409db55e1c64239e002fca509dc754cb Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Tue, 7 Jul 2026 10:41:03 -0300 Subject: [PATCH 38/55] [#49]: Document kill-resilient recording design Explain why .m4a partials are unrecoverable, the ADTS AAC segmented model, the rehydrate/resume/stop/discard lifecycle, and the caveats (absolute-URI segments, on-device validation, .aac downstream). Refs #49 --- docs/recording-kill-resilience.md | 119 ++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 docs/recording-kill-resilience.md diff --git a/docs/recording-kill-resilience.md b/docs/recording-kill-resilience.md new file mode 100644 index 0000000..5b21d13 --- /dev/null +++ b/docs/recording-kill-resilience.md @@ -0,0 +1,119 @@ +# Kill-resilient partial recordings + +How Fluent Mobile keeps an in-progress verse recording recoverable — and +resumable — even when the app process is killed by the OS or by the user. + +## Problem + +`expo-audio`'s `HIGH_QUALITY` preset records MPEG-4 AAC (`.m4a`). An MP4/M4A +container is only valid once its `moov` atom is written, and that happens on a +clean `recorder.stop()`. If the process dies mid-recording (OS reclaim, force +stop, crash), the partial file on disk has no `moov` atom: it is **unplayable +and un-appendable**. That makes a true "resume after kill" impossible with the +default format — the bytes captured before the kill are effectively lost. + +Backgrounding-without-kill was already handled separately (auto-pause on +background, guarded resume on foreground). This document covers the harder case: +the JS runtime and native recorder are gone. + +## Solution + +Two changes make partial takes durable: + +1. **Record ADTS AAC (`.aac`) instead of `.m4a`.** ADTS is a self-framing + bitstream — every frame carries its own header, so there is no trailing + metadata to lose. A segment truncated by a process kill stays playable up to + the last complete frame, and two ADTS files concatenate into one valid file + by plain byte append. + +2. **Model a take as an ordered list of segments.** Each continuous recording + session (within one app lifetime) writes one segment. A live take is a single + segment; resuming after a kill opens a **new** segment appended to the + rehydrated ones. On stop, the segments are concatenated into the committed + take. + +A persisted marker (the "manifest") records the segment list, accumulated +elapsed time, and start timestamp so the take can be rehydrated on the next app +launch. + +## Lifecycle + +```mermaid +flowchart TD + start["Start recording"] --> seg0["Open segment 0
(document dir, .aac)"] + seg0 --> rec["Recording"] + rec -->|pause / background| paused["Paused
persist marker:
segments + elapsedMs"] + paused -->|resume, same session| rec + + rec -->|process killed| dead["Process gone
segment 0 still playable (ADTS)"] + paused -->|process killed| dead + + dead -->|relaunch| rehydrate["Rehydrate from marker
status = Paused, isRecovered = true"] + rehydrate -->|Resume| seg1["Open segment 1
(new file, elapsed preserved)"] + rehydrate -->|Discard| discard["Unlink all segments
clear marker"] + seg1 --> rec + + rec -->|Stop| concat["Concatenate segments
(byte append)"] + concat --> move["Move merged file into
durable recordings tree"] + move --> cleanup["Unlink raw segments"] + cleanup --> insert["Insert DB row"] + insert --> review["Review"] +``` + +## Key components + +| Concern | Location | +|---|---| +| Record as ADTS AAC | `src/hooks/useRecorder.ts` — `useAudioRecorder({ extension: '.aac', android: { outputFormat: 'aac_adts', audioEncoder: 'aac' } })` | +| Segment tracking + resume-after-kill | `src/hooks/useRecorder.ts` — `segmentsRef`, `startRecordingSession()`, `continueRecordingSession()`, `resume()` | +| `isRecovered` flag (rehydrated take) | `src/hooks/useRecorder.ts` — surfaced in the hook API | +| Segment manifest persistence | `src/services/storage.ts` — `PausedTakeMarker.segments`, legacy `fileUri` coercion | +| Concatenation | `src/services/recordingStorage.ts` — `concatenateAacSegments()` | +| Merge → move → cleanup → insert | `src/app/tabs/drafting/record/hooks/useVerseRecorder.ts` — `onCommit`, `deletePausedFiles` | +| UI (Resume / Stop / Discard + copy) | `src/app/tabs/drafting/record/components/RecordingControls.tsx` | + +## Manifest shape + +`PausedTakeMarker` (persisted in KV storage, keyed by `bibleTextId`): + +```ts +interface PausedTakeMarker { + bibleTextId: number; + segments: string[]; // ordered absolute file URIs, one per session + elapsedMs: number; // accumulated active recording time + startedAt: string; // ISO timestamp of the first session + sessionToken?: string; // present on live markers +} +``` + +`getPausedTake()` coerces a legacy single-`fileUri` marker (written before this +change) into a one-segment list, so a take paused across the upgrade still +loads. + +## Resume-after-kill flow + +1. On mount, `useVerseRecorder` reads the marker via `getPausedTake()`. +2. `useRecorder` rehydrates: `status = Paused`, `canResume = true`, + `isRecovered = true`, `elapsedMs = marker.elapsedMs`, and + `segmentsRef = marker.segments`. There is no live native session, so + `liveSessionTokenRef` stays `null`. +3. **Resume** calls `continueRecordingSession()`: it prepares and starts a fresh + recording (a new segment file), preserves the accumulated elapsed time and + `startedAt`, and pushes the new segment onto `segmentsRef`. +4. **Stop** concatenates every segment, moves the merged file into the durable + `recordings/` tree, unlinks the raw segments, and inserts the DB row. +5. **Discard** unlinks all segments and clears the marker. + +## Caveats + +- **Segment paths are absolute URIs.** This is simple and matches the prior + working behavior. The tradeoff: an in-progress take would not survive a + document-directory relocation across an app update/reinstall. It fully + survives the primary case — a process kill within the same install. +- **On-device validation.** Concatenation assumes `expo-audio` writes each + segment into the directory that `expo-file-system`'s `Paths.document` + resolves to. This can't be exercised in Jest; smoke-test on a device: + record → kill app → relaunch → resume → stop → play. +- **ADTS vs M4A quality/size.** ADTS AAC is functionally equivalent audio; the + container difference is what buys resilience. Downstream upload/playback must + accept `.aac`. From 2dce913e01e7e8df370e691fb195f1e8dce74212 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Tue, 7 Jul 2026 12:06:47 -0300 Subject: [PATCH 39/55] [#49]: Persist recovery marker at recording start Write the paused-take manifest the moment recording starts (and refresh it on pause), not only on pause/background. A hard task-swipe kill can destroy the process before the background auto-pause runs, so a pause-only marker never existed for the very case recovery targets. Capture the verse's navigation context (chapterAssignmentId, verseNumber) on the marker so a recovered take can be surfaced and navigated to without a reverse lookup, and add findPausedTake() to fetch the single outstanding marker for the home prompt. Refs #49 --- .../record/hooks/useVerseRecorder.test.ts | 34 ++++++ .../drafting/record/hooks/useVerseRecorder.ts | 11 +- src/hooks/useRecorder.test.ts | 20 ++++ src/hooks/useRecorder.ts | 56 ++++++---- src/services/storage.test.ts | 100 ++++++++++++++---- src/services/storage.ts | 94 ++++++++++------ 6 files changed, 241 insertions(+), 74 deletions(-) diff --git a/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts b/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts index 9ac0d5f..596fffb 100644 --- a/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts +++ b/src/app/tabs/drafting/record/hooks/useVerseRecorder.test.ts @@ -189,6 +189,40 @@ describe('useVerseRecorder', () => { } }); + it('persists the navigation context alongside the paused marker', async () => { + jest.useFakeTimers(); + try { + const { result } = renderHook(() => + useVerseRecorder({ + bibleTextId: 42, + chapterAssignmentId: 88, + verseNumber: 5, + }), + ); + await waitReady(result); + + jest.setSystemTime(new Date('2026-07-01T00:00:00.000Z')); + await act(async () => { + await result.current.start(); + }); + + jest.setSystemTime(new Date('2026-07-01T00:00:01.000Z')); + await act(async () => { + await result.current.pause(); + }); + + expect(mockSetPausedTake).toHaveBeenCalledWith( + expect.objectContaining({ + bibleTextId: 42, + chapterAssignmentId: 88, + verseNumber: 5, + }), + ); + } finally { + jest.useRealTimers(); + } + }); + it('commits a stopped take into durable storage and the DB', async () => { jest.useFakeTimers(); try { diff --git a/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts b/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts index 2b308ca..29f83e3 100644 --- a/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts +++ b/src/app/tabs/drafting/record/hooks/useVerseRecorder.ts @@ -72,7 +72,16 @@ export function useVerseRecorder({ persistPaused: (state: PausedTakeState) => { if (bibleTextId === null) return; - setPausedTake({ bibleTextId, ...state }); + setPausedTake({ + bibleTextId, + ...(chapterAssignmentId !== null && chapterAssignmentId !== undefined + ? { chapterAssignmentId } + : {}), + ...(verseNumber !== null && verseNumber !== undefined + ? { verseNumber } + : {}), + ...state, + }); }, clearPaused: () => { diff --git a/src/hooks/useRecorder.test.ts b/src/hooks/useRecorder.test.ts index 86d947b..29691f9 100644 --- a/src/hooks/useRecorder.test.ts +++ b/src/hooks/useRecorder.test.ts @@ -253,6 +253,26 @@ describe('useRecorder', () => { expect(result.current.status).toBe(RecorderStatus.Recording); }); + it('persists a recoverable marker as soon as recording starts', async () => { + // A hard process kill can happen before any pause/background event runs, so + // the manifest must exist from the first moment of recording. + const adapter = makeAdapter(); + const { result } = renderHook(() => useRecorder(adapter)); + await waitReady(result); + + await act(async () => { + await result.current.start(); + }); + + expect(adapter.persistPaused).toHaveBeenCalledWith( + expect.objectContaining({ + segments: ['file:///tmp/take-1.aac'], + elapsedMs: 0, + sessionToken: expect.any(String), + }), + ); + }); + it('accumulates only active recording time across pause and resume', async () => { jest.useFakeTimers(); diff --git a/src/hooks/useRecorder.ts b/src/hooks/useRecorder.ts index 371cb4d..8b25705 100644 --- a/src/hooks/useRecorder.ts +++ b/src/hooks/useRecorder.ts @@ -212,6 +212,30 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { } }, []); + // Persist the in-flight take's manifest (segments + accumulated elapsed + + // start time). Written at session start and on pause, so a hard process kill + // — which may not give the background auto-pause a chance to run — still + // leaves a recoverable marker. No-op until a live session owns at least one + // segment. + const persistLiveMarker = useCallback((elapsed: number) => { + const currentSessionKey = sessionKeyRef.current; + const sessionToken = liveSessionTokenRef.current; + if ( + currentSessionKey === null || + sessionToken === null || + segmentsRef.current.length === 0 + ) + return; + const startedAt = startedAtRef.current ?? new Date().toISOString(); + startedAtRef.current = startedAt; + adapterRef.current.persistPaused({ + segments: segmentsRef.current, + elapsedMs: elapsed, + startedAt, + sessionToken, + }); + }, []); + const startTicking = useCallback(() => { clearTick(); tickRef.current = setInterval(() => { @@ -342,25 +366,6 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { } }, [recorder]); - const persistPausedMarker = useCallback((elapsed: number) => { - const currentSessionKey = sessionKeyRef.current; - const sessionToken = liveSessionTokenRef.current; - if ( - currentSessionKey === null || - sessionToken === null || - segmentsRef.current.length === 0 - ) - return; - const startedAt = startedAtRef.current ?? new Date().toISOString(); - startedAtRef.current = startedAt; - adapterRef.current.persistPaused({ - segments: segmentsRef.current, - elapsedMs: elapsed, - startedAt, - sessionToken, - }); - }, []); - const requestPermission = useCallback(async (): Promise => { const response = await requestRecordingPermissionsAsync(); @@ -385,9 +390,11 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { setElapsedMs(0); recorder.record(); registerCurrentSegment(); + // Persist immediately so a kill right after start is still recoverable. + persistLiveMarker(0); setStatus(RecorderStatus.Recording); startTicking(); - }, [recorder, registerCurrentSegment, startTicking]); + }, [persistLiveMarker, recorder, registerCurrentSegment, startTicking]); // Resume a take rehydrated after a process kill: open a NEW segment appended // to the restored ones, preserving the accumulated elapsed time and started-at @@ -399,11 +406,14 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { runningSinceRef.current = Date.now(); recorder.record(); registerCurrentSegment(); + // Persist the appended segment immediately so a kill right after resuming + // is still recoverable. + persistLiveMarker(baseElapsedRef.current); setCanResume(false); setIsRecovered(false); setStatus(RecorderStatus.Recording); startTicking(); - }, [recorder, registerCurrentSegment, startTicking]); + }, [persistLiveMarker, recorder, registerCurrentSegment, startTicking]); const start = useCallback(async () => { const currentSessionKey = sessionKeyRef.current; @@ -431,8 +441,8 @@ export function useRecorder(adapter: RecorderAdapter): UseRecorderApi { setElapsedMs(finalElapsed); setStatus(RecorderStatus.Paused); setCanResume(liveSessionTokenRef.current !== null); - persistPausedMarker(finalElapsed); - }, [clearTick, persistPausedMarker, recorder, status]); + persistLiveMarker(finalElapsed); + }, [clearTick, persistLiveMarker, recorder, status]); const pause = useCallback(async () => { await pauseInternal(); diff --git a/src/services/storage.test.ts b/src/services/storage.test.ts index e3a66c3..d667da1 100644 --- a/src/services/storage.test.ts +++ b/src/services/storage.test.ts @@ -3,11 +3,17 @@ jest.mock('@op-engineering/op-sqlite', () => ({ getItemSync: jest.fn(), setItemSync: jest.fn(), removeItemSync: jest.fn(), + getAllKeys: jest.fn(), })), })); import { Storage } from '@op-engineering/op-sqlite'; -import { clearPausedTake, getPausedTake, setPausedTake } from './storage'; +import { + clearPausedTake, + findPausedTake, + getPausedTake, + setPausedTake, +} from './storage'; const mockGetItemSync = jest.mocked(Storage).mock.results[0]!.value .getItemSync as jest.Mock; @@ -15,6 +21,8 @@ const mockSetItemSync = jest.mocked(Storage).mock.results[0]!.value .setItemSync as jest.Mock; const mockRemoveItemSync = jest.mocked(Storage).mock.results[0]!.value .removeItemSync as jest.Mock; +const mockGetAllKeys = jest.mocked(Storage).mock.results[0]!.value + .getAllKeys as jest.Mock; const validMarker = { bibleTextId: 42, @@ -36,24 +44,6 @@ describe('getPausedTake', () => { expect(mockGetItemSync).toHaveBeenCalledWith('paused_take:42'); }); - it('coerces a legacy single-fileUri marker into a one-segment list', () => { - mockGetItemSync.mockReturnValue( - JSON.stringify({ - bibleTextId: 42, - fileUri: 'file:///docs/legacy-take.m4a', - elapsedMs: 4500, - startedAt: '2026-07-01T00:00:00.000Z', - }), - ); - - expect(getPausedTake(42)).toEqual({ - bibleTextId: 42, - segments: ['file:///docs/legacy-take.m4a'], - elapsedMs: 4500, - startedAt: '2026-07-01T00:00:00.000Z', - }); - }); - it('returns null when bibleTextId is missing', () => { mockGetItemSync.mockReturnValue( JSON.stringify({ @@ -86,7 +76,7 @@ describe('getPausedTake', () => { expect(getPausedTake(42)).toBeNull(); }); - it('returns null when neither segments nor a legacy fileUri are present', () => { + it('returns null when segments are absent', () => { mockGetItemSync.mockReturnValue( JSON.stringify({ bibleTextId: validMarker.bibleTextId, @@ -111,6 +101,76 @@ describe('getPausedTake', () => { expect(getPausedTake(42)).toBeNull(); }); + + it('passes through the navigation context when present', () => { + mockGetItemSync.mockReturnValue( + JSON.stringify({ + ...validMarker, + chapterAssignmentId: 88, + verseNumber: 5, + }), + ); + + expect(getPausedTake(42)).toEqual({ + ...validMarker, + chapterAssignmentId: 88, + verseNumber: 5, + }); + }); +}); + +describe('findPausedTake', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetAllKeys.mockReturnValue([]); + mockGetItemSync.mockReturnValue(null); + }); + + function markerFor(bibleTextId: number, startedAt: string) { + return { + bibleTextId, + segments: [`file:///docs/take-${bibleTextId}.aac`], + elapsedMs: 1000, + startedAt, + }; + } + + it('returns null when no keys exist', () => { + mockGetAllKeys.mockReturnValue([]); + expect(findPausedTake()).toBeNull(); + }); + + it('returns the marker from a paused-take key, ignoring unrelated ones', () => { + const markerA = markerFor(1, '2026-07-01T00:00:00.000Z'); + mockGetAllKeys.mockReturnValue([ + 'userId', + 'paused_take:1', + 'last_synced_at', + ]); + mockGetItemSync.mockImplementation((key: string) => + key === 'paused_take:1' ? JSON.stringify(markerA) : 'ignored', + ); + + expect(findPausedTake()).toEqual(markerA); + }); + + it('skips malformed or mismatched markers and returns the first valid one', () => { + mockGetAllKeys.mockReturnValue([ + 'paused_take:oops', + 'paused_take:2', + 'paused_take:1', + ]); + mockGetItemSync.mockImplementation((key: string) => { + if (key === 'paused_take:1') + return JSON.stringify(markerFor(1, '2026-07-01T00:00:00.000Z')); + if (key === 'paused_take:2') + // bibleTextId mismatches the key -> rejected + return JSON.stringify(markerFor(999, '2026-07-01T00:00:00.000Z')); + return '{not json'; + }); + + expect(findPausedTake()).toEqual(markerFor(1, '2026-07-01T00:00:00.000Z')); + }); }); describe('setPausedTake / clearPausedTake', () => { diff --git a/src/services/storage.ts b/src/services/storage.ts index 8ee6b82..46f6bc1 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -191,45 +191,42 @@ export interface PausedTakeMarker { elapsedMs: number; startedAt: string; sessionToken?: string; + /** + * Navigation context captured when the take was paused, so a recovered take + * can be surfaced (and navigated to) from the home screen without a fragile + * reverse lookup. Optional because the recorder can run without a resolved + * verse (e.g. in isolation/tests); the home recovery prompt only fires when + * both are present. + */ + chapterAssignmentId?: number; + verseNumber?: number; } +const PAUSED_TAKE_PREFIX = 'paused_take:'; + function pausedTakeKey(bibleTextId: number): string { - return `paused_take:${bibleTextId}`; + return `${PAUSED_TAKE_PREFIX}${bibleTextId}`; } /** - * Accepts the current `segments` array and also coerces a legacy single - * `fileUri` marker into a one-segment list, so a take paused before this change - * still loads. Returns null when neither shape is present/valid. + * Parses and validates a stored marker payload, requiring it to belong to + * `expectedBibleTextId`. Returns null for malformed/mismatched payloads. */ -function normalizeSegments(parsed: { - segments?: unknown; - fileUri?: unknown; -}): string[] | null { - if ( - Array.isArray(parsed.segments) && - parsed.segments.length > 0 && - parsed.segments.every(segment => typeof segment === 'string') - ) { - return parsed.segments as string[]; - } - if (typeof parsed.fileUri === 'string') { - return [parsed.fileUri]; - } - return null; -} - -export function getPausedTake(bibleTextId: number): PausedTakeMarker | null { - const raw = kvStorage.getItemSync(pausedTakeKey(bibleTextId)); - if (!raw) return null; +function parsePausedTakeMarker( + raw: string, + expectedBibleTextId: number, +): PausedTakeMarker | null { try { - const parsed = JSON.parse(raw) as Partial & { - fileUri?: string; - }; - const segments = normalizeSegments(parsed); + const parsed = JSON.parse(raw) as Partial; + const segments = + Array.isArray(parsed.segments) && + parsed.segments.length > 0 && + parsed.segments.every(segment => typeof segment === 'string') + ? (parsed.segments as string[]) + : null; if ( Number.isFinite(parsed?.bibleTextId) && - parsed.bibleTextId === bibleTextId && + parsed.bibleTextId === expectedBibleTextId && segments !== null && typeof parsed?.elapsedMs === 'number' && typeof parsed?.startedAt === 'string' @@ -242,15 +239,52 @@ export function getPausedTake(bibleTextId: number): PausedTakeMarker | null { ...(typeof parsed.sessionToken === 'string' ? { sessionToken: parsed.sessionToken } : {}), + ...(Number.isFinite(parsed.chapterAssignmentId) + ? { chapterAssignmentId: parsed.chapterAssignmentId } + : {}), + ...(Number.isFinite(parsed.verseNumber) + ? { verseNumber: parsed.verseNumber } + : {}), }; } return null; } catch (error) { - log.error('Failed to parse paused take marker', { bibleTextId, error }); + log.error('Failed to parse paused take marker', { + bibleTextId: expectedBibleTextId, + error, + }); return null; } } +export function getPausedTake(bibleTextId: number): PausedTakeMarker | null { + const raw = kvStorage.getItemSync(pausedTakeKey(bibleTextId)); + if (!raw) return null; + return parsePausedTakeMarker(raw, bibleTextId); +} + +/** + * Returns the first valid persisted paused-take marker, or null if none exists. + * Used by the home screen to surface a recording recovered after a process kill. + * A forced Continue/Discard decision keeps at most one marker around at a time, + * so a single result is enough; malformed or mismatched markers are skipped. + */ +export function findPausedTake(): PausedTakeMarker | null { + const keys = kvStorage.getAllKeys(); + for (const key of keys) { + if (typeof key !== 'string' || !key.startsWith(PAUSED_TAKE_PREFIX)) { + continue; + } + const bibleTextId = Number(key.slice(PAUSED_TAKE_PREFIX.length)); + if (!Number.isFinite(bibleTextId)) continue; + const raw = kvStorage.getItemSync(key); + if (!raw) continue; + const marker = parsePausedTakeMarker(raw, bibleTextId); + if (marker) return marker; + } + return null; +} + export function setPausedTake(marker: PausedTakeMarker) { kvStorage.setItemSync( pausedTakeKey(marker.bibleTextId), From d7a389e5fcb8fda87fd618a8dbe5a6d88c3b87a6 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Tue, 7 Jul 2026 12:06:55 -0300 Subject: [PATCH 40/55] [#49]: Resolve verse navigation from a chapter assignment Add getVerseDetailNavByChapterAssignment() so a recovered take, which only carries its chapterAssignmentId, can rebuild the VerseDetail route params (chapter id/name, project name, language) needed to navigate. Refs #49 --- src/db/queries.nav.test.ts | 75 ++++++++++++++++++++++++++++++++++++++ src/db/queries.ts | 59 ++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/db/queries.nav.test.ts diff --git a/src/db/queries.nav.test.ts b/src/db/queries.nav.test.ts new file mode 100644 index 0000000..f40b43d --- /dev/null +++ b/src/db/queries.nav.test.ts @@ -0,0 +1,75 @@ +import { getVerseDetailNavByChapterAssignment } from './queries'; + +interface MockDb { + execute: jest.Mock; +} + +let mockDb: MockDb; + +jest.mock('./db', () => ({ + getDatabase: () => mockDb, +})); + +describe('getVerseDetailNavByChapterAssignment', () => { + it('maps a row into VerseDetail route params', async () => { + mockDb = { + execute: jest.fn().mockResolvedValue({ + rows: [ + { + chapter_id: 88, + chapter_number: 1, + book_name: 'Genesis', + project_name: 'Demo Project', + target_language_name: 'Swahili', + }, + ], + }), + }; + + const result = await getVerseDetailNavByChapterAssignment(88); + + expect(result).toEqual({ + chapterId: 88, + chapterName: 'Genesis 1', + projectName: 'Demo Project', + language: 'Swahili', + }); + expect(mockDb.execute).toHaveBeenCalledWith( + expect.stringMatching(/FROM chapter_assignments/), + [88], + ); + }); + + it('tolerates a missing language and book name', async () => { + mockDb = { + execute: jest.fn().mockResolvedValue({ + rows: [ + { + chapter_id: 88, + chapter_number: 3, + book_name: null, + project_name: null, + target_language_name: null, + }, + ], + }), + }; + + expect(await getVerseDetailNavByChapterAssignment(88)).toEqual({ + chapterId: 88, + chapterName: '3', + projectName: '', + language: '', + }); + }); + + it('returns null when no row matches', async () => { + mockDb = { execute: jest.fn().mockResolvedValue({ rows: [] }) }; + expect(await getVerseDetailNavByChapterAssignment(88)).toBeNull(); + }); + + it('returns null when the query throws', async () => { + mockDb = { execute: jest.fn().mockRejectedValue(new Error('boom')) }; + expect(await getVerseDetailNavByChapterAssignment(88)).toBeNull(); + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index 5368c2b..b662f2f 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -398,6 +398,65 @@ export async function getProjectIdForProjectUnit( } } +export interface VerseDetailNavParams { + chapterId: number; + chapterName: string; + projectName: string; + language: string; +} + +/** + * Resolves the `VerseDetail` route params for a chapter assignment, so a + * recovered paused take (which stores only its `chapterAssignmentId`) can be + * navigated to from the home screen. Not user-scoped, so it also resolves + * chapters opened via the Projects tab. + */ +export async function getVerseDetailNavByChapterAssignment( + chapterAssignmentId: number, +): Promise { + const db = getDatabase(); + try { + const result = await db.execute( + `SELECT + ca.id AS chapter_id, + ca.chapter_number, + b.eng_display_name AS book_name, + p.name AS project_name, + tl.lang_name AS target_language_name + FROM chapter_assignments ca + JOIN books b ON ca.book_id = b.id + JOIN project_units pu ON ca.project_unit_id = pu.id + JOIN projects p ON pu.project_id = p.id + LEFT JOIN languages tl ON p.target_language_id = tl.id + WHERE ca.id = ? + LIMIT 1`, + [chapterAssignmentId], + ); + const row = ( + result?.rows as unknown as { + chapter_id: number; + chapter_number: number; + book_name: string | null; + project_name: string | null; + target_language_name: string | null; + }[] + )?.[0]; + if (!row) return null; + return { + chapterId: row.chapter_id, + chapterName: `${row.book_name ?? ''} ${row.chapter_number}`.trim(), + projectName: row.project_name ?? '', + language: row.target_language_name ?? '', + }; + } catch (error) { + log.error('Error resolving verse detail nav by chapter assignment', { + chapterAssignmentId, + error, + }); + return null; + } +} + /** Resolves the `bible_texts.id` PK a recording joins against. */ export async function getBibleTextId( bibleId: number, From 149de3fa55889195567c00382e03894020e89fc6 Mon Sep 17 00:00:00 2001 From: Felipe Cesar Date: Tue, 7 Jul 2026 12:07:03 -0300 Subject: [PATCH 41/55] [#49]: Prompt to recover a killed recording from home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the home screen (after sync) surface any recording recovered after a process kill and force a decision: Continue navigates to that verse's Record tab — landing on the recovered verse via a new recoverVerse route param, where the paused take rehydrates — or Discard deletes the partial segments. The prompt is skipped when the take can't be resolved to a verse. Refs #49 --- src/app/screens/DraftingScreen.tsx | 11 +- src/app/screens/HomeScreen.tsx | 5 + .../hooks/useRecordingRecovery.test.ts | 138 ++++++++++++++++++ src/app/screens/hooks/useRecordingRecovery.ts | 98 +++++++++++++ src/types/navigation/types.ts | 5 + 5 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 src/app/screens/hooks/useRecordingRecovery.test.ts create mode 100644 src/app/screens/hooks/useRecordingRecovery.ts diff --git a/src/app/screens/DraftingScreen.tsx b/src/app/screens/DraftingScreen.tsx index 94da30b..cae29c8 100644 --- a/src/app/screens/DraftingScreen.tsx +++ b/src/app/screens/DraftingScreen.tsx @@ -40,7 +40,7 @@ type Route = RouteProp; export default function DraftingScreen() { const navigation = useNavigation