npm warn Unknown env config "http-proxy". This will stop working in the next major version of npm.

> sawaup@3.6.1 test
> node --test tests/*.test.mjs

✔ Athar profile has Wamda publish, notifications, DMs and public/private control (0.905447ms)
✔ right swipe returns to phone chats and left swipe opens Athar profile (0.152934ms)
✔ private Athar follows use requests with accept and reject (0.184951ms)
✔ Athar direct messages are separate backend records (0.14983ms)
✔ private Wamdat are filtered from discovery unless viewer is accepted follower (0.159143ms)
✔ background notification task is registered and stores messages locally (0.756048ms)
✔ push transport sends a visible alert plus a high-priority headless cache copy (0.210669ms)
✔ Android separates message and call notification channels (0.130872ms)
✔ message and call pushes carry enough payload for local cache and routing (0.199352ms)
✔ new groups notify initial members immediately (0.165743ms)
✔ cache serializes concurrent merges without losing messages (9.473486ms)
✔ cache does not silently erase corrupt local history (1.215381ms)
✖ caller negotiates video without opening camera and requests earpiece (3.319726ms)
✖ callee uses the offered video sender instead of adding an unmatched transceiver (0.760314ms)
✖ audio track arriving after video preserves both remote tracks (0.759733ms)
✔ calls keep STUN and support authenticated multi-endpoint TURN fallback (0.907309ms)
✖ unified call starts audio-only and can enable camera without starting a new call (1.80793ms)
✔ Arabic digits and Oman phone normalize (1.832997ms)
✔ username normalize (0.266801ms)
✔ profile search accepts username and phone (0.909333ms)
✔ poll and event previews are readable (0.246291ms)
✔ attachment parsing (0.130571ms)
✔ audio preview (0.108219ms)
✔ message merge keeps receipts and local media (0.185272ms)
✔ one-view effects cannot reopen after consumption (0.214434ms)
✔ room recipients are unique and include sender (0.425203ms)
✔ room relay rejects a sender who is not a member (0.708989ms)
✔ v2.4 SQL migration defines durable poll votes and event RSVP tables (0.499943ms)
✔ v2.5 group moderation migration adds durable muted state (0.205812ms)
✔ Athar uses the account-wide username and does not expose a separate editable username (0.854582ms)
✔ keyboard uses one resize policy and keeps active forms scrollable above the keyboard (0.930824ms)
✔ Android push registration diagnoses native token and retries Expo token (0.708017ms)
✔ slow Y distance locks before release regardless of speed or original button bounds (3.196515ms)
✔ release event reaching threshold locks even if no move event was delivered (0.780724ms)
✔ release below threshold sends once despite duplicate release events (0.912587ms)
✔ release during permission is honored after preparation (0.86658ms)
✔ listen once is carried in the sent attachment options, preview is optional (1.012584ms)
✔ system gesture cancellation discards recording (0.784169ms)
✔ inbox reads stored wamda likes/comments with actor profiles and retains follows (7.614742ms)
✔ public map retry reuses uploaded media and record id after metadata network error (1.751658ms)
✔ invalid coordinates fail before upload (0.871918ms)
✔ AccountScreen does not shadow the imported setBlocked service (1.430747ms)
✔ unblock button calls the service then refreshes the local blocked list (0.368229ms)
✔ all JSX press controls are wired to handlers (0.83216ms)
✔ direct chat plus menu exposes the seven requested actions (0.52516ms)
✔ contact picker searches by phone or username (0.158843ms)
✔ group plus menu exposes the same seven requested actions (0.993776ms)
✔ poll cards support voting, changing vote, counts and percentages (0.640038ms)
✔ event cards support RSVP and add-to-calendar (0.271899ms)
✔ bottom navigation has no rooms tab and keeps chats as launch screen (0.68838ms)
✔ chat composers use Android resize so the full composer clears the keyboard (0.903024ms)
✔ groups live inside chats and support immediate moderation (0.325117ms)
✔ calls use one confirmation action and camera is enabled inside the call (1.045173ms)
✔ 24-hour Athar prefers durable local media after publish (0.384774ms)
✔ notification responses deep-link to chats, groups, calls (0.493143ms)
✔ returning from another app repaints chats from local phone storage before network (0.122419ms)
✔ voice recorder uses hold-to-record, slide-up lock, review, delete and send (0.267452ms)
✖ unified call starts audio-only and negotiates dormant video before offer (2.0125ms)
✖ camera is attached on demand to the pre-negotiated video sender (0.639648ms)
✔ 3.1.2 implements owner-only public Athar deletion (0.863837ms)
✔ 3.1.2 implements owner-only Wamda deletion and UI removal (0.148047ms)
✔ 3.1.2 downloads Wamda media into the device library without bypassing reshare choice (0.203258ms)
✔ 3.1.2 keeps Wamda pin migration and media-library config in the full build (0.221495ms)
✔ 3.1.2 hardens active test-stage RLS away from bare true policies (0.166884ms)
✔ Storage requests send the same test-stage gate key required by media RLS (0.229116ms)
✔ 3.1.1 autolinks the local isolated face-warp Expo module (0.942963ms)
✖ Android face warp owns a separate CameraX + ML Kit pipeline with frame dropping (1.960914ms)
✖ native face analysis prefers 468-point Face Mesh and falls back to contours for unsafe or missed frames (0.697873ms)
✖ CameraX surface lifetime is owned until SurfaceRequest detaches (0.555084ms)
✖ GPU renderer performs inverse pixel deformation instead of overlay-only drawing (0.404623ms)
✔ mesh-guided deformation uses feature-specific radii instead of one generic face box (0.205631ms)
✔ all agreed geometry-changing filters have native warp parameter presets (0.187435ms)
✖ warp failure fails open to the stable VisionCamera camera path (0.631365ms)
✔ normal and decorative camera paths remain available and filtered media records the visible composition (0.199623ms)
✔ face warp module is Android-first and iOS remains a safe fallback (0.912317ms)
✖ Android native warp pins display rotation and guards torch until CameraX is OPEN (0.588483ms)
✔ 3.1 keeps direct and group swipe reply plus sticker sending (1.120392ms)
✔ 3.1 conversation camera is wired for direct and group capture/review (0.265489ms)
✔ 3.1 notification reply and previews cover message variants (0.273872ms)
✔ 3.1 keyboard fix uses native Android resize rather than the old insufficient height offset (2.09372ms)
✖ 3.1 call path hardens permissions, startup timeouts, actual connection state and self view (1.351311ms)
✔ 3.1 Athar trend, pull refresh and pinned Wamdat are wired (0.231709ms)
✔ 3.1 keeps the stable simple receipt UI (0.340298ms)
✔ interactive message notifications register a native text reply action (1.051571ms)
✔ main profile save verifies and caches the server result (0.175176ms)
✔ Athar profile save verifies persisted bio and follow lists resolve missing Athar profiles (0.192362ms)
✔ friend Athar recipient picker hydrates cached people and groups before network refresh (0.204009ms)
✔ Athar profile exposes chat, Wamdat and working menu actions (0.143641ms)
✔ voice recording keeps the requested hold-release-lock-review gesture (0.196358ms)
✔ 3.1 preserves the confirmed successful chat improvements (2.000102ms)
✔ 3.1 fixes the reported chat regressions without restoring failed receipt text (0.40306ms)
✔ call timeline groups missed calls and formats answered duration (14.662192ms)
✔ full 3.1 build advances beyond the failed build 18 (0.228364ms)
✔ Athar high-volume lists use bounded pagination instead of 1000/5000 row pulls (1.156686ms)
✔ Athar profile counters use HEAD exact-count queries instead of downloading rows (0.214444ms)
✔ friend Athar and public Athar are separate entry points (1.43241ms)
✔ friend story + badge is lower-left (0.185862ms)
✔ simple mode hides only the public Athar tab (0.276755ms)
✔ Athar camera has glass search and four-way gestures (0.280081ms)
✔ Wamdat is a vertical paged video feed with search discovery (0.33388ms)
✔ public Athar profile is independent and supports follows (0.17029ms)
✔ public map stores approximate coordinates only from opt-in publish (0.181406ms)
✔ public reshares do not let friend-story cleanup delete shared media (0.382991ms)
✔ friend Athar create button opens live camera instead of treating press event as media (0.433556ms)
✔ v3 uses MapLibre with New Architecture and no react-native-maps dependency (2.644176ms)
✖ Athar live camera is driven by VisionCamera face detection during preview (1.211015ms)
✔ camera wrapper saves the same live filter composition for photos and videos (0.256025ms)
✔ Athar map clusters nearby public posts and expands clusters on tap (0.144101ms)
✔ all 20 agreed Athar filter names are present (0.17777ms)
✔ push delivery has visible OS alerts, background cache copy, call channel, and registration diagnostics (0.192582ms)
✔ native build number identifies the full 3.1 repository build (0.141096ms)
✔ voice hold release keeps immediate-send behavior from the working v3.0.0 path (0.974909ms)
✔ slide-up locks recording and provides recipient listen-once option (0.184981ms)
✔ recording composer replaces the text field with waveform and elapsed duration (0.24559ms)
✔ audio uploads use an audio MIME type instead of falling through to image/jpeg (0.208256ms)
ℹ tests 115
ℹ suites 0
ℹ pass 101
ℹ fail 14
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 459.208548

✖ failing tests:

test at tests/call-behavior.test.mjs:20:1
✖ caller negotiates video without opening camera and requests earpiece (3.319726ms)
  ReferenceError: videoSenderRef is not defined
      at Object.NativeCall (evalmachine.<anonymous>:324:5)
      at harness (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/call-behavior.test.mjs:18:54)
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/call-behavior.test.mjs:20:95)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.start (node:internal/test_runner/test:1242:17)
      at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17)

test at tests/call-behavior.test.mjs:21:1
✖ callee uses the offered video sender instead of adding an unmatched transceiver (0.760314ms)
  ReferenceError: videoSenderRef is not defined
      at Object.NativeCall (evalmachine.<anonymous>:324:5)
      at harness (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/call-behavior.test.mjs:18:54)
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/call-behavior.test.mjs:21:106)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)

test at tests/call-behavior.test.mjs:22:1
✖ audio track arriving after video preserves both remote tracks (0.759733ms)
  ReferenceError: videoSenderRef is not defined
      at Object.NativeCall (evalmachine.<anonymous>:324:5)
      at harness (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/call-behavior.test.mjs:18:54)
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/call-behavior.test.mjs:22:88)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async Test.processPendingSubtests (node:internal/test_runner/test:960:7)

test at tests/call-network-regression.test.mjs:15:1
✖ unified call starts audio-only and can enable camera without starting a new call (1.80793ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /acquireMedia\(\{ audio: true, video: false \}/. Input:
  
  "import React, { useEffect, useRef, useState } from 'react';\n" +
    "import { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\n" +
    "import { SafeAreaView } from 'react-native-safe-area-context';\n" +
    "import { Ionicons } from '@expo/vector-icons';\n" +
    "import { setAudioModeAsync } from 'expo-audio';\n" +
    'import {\n' +
    '  RTCPeerConnection,\n' +
    '  RTCIceCandidate,\n' +
    '  RTCSessionDescription,\n' +
    '  RTCView,\n' +
    '  mediaDevices,\n' +
    "} from 'react-native-webrtc';\n" +
    "import { ICE_SERVERS, fallbackName, nowIso } from './core';\n" +
    "import { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n" +
    '\n' +
    'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n' +
    '\n' +
    'function IconButton({ icon, onPress, active, danger, disabled }) {\n' +
    '  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n' +
    '    <Ionicons name={icon} size={26} color="#fff" />\n' +
    '  </Pressable>;\n' +
    '}\n' +
    '\n' +
    "export default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n" +
    '  const [callRow, setCallRow] = useState(incomingCall || null);\n' +
    "  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n" +
    "  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '  const [localStream, setLocalStream] = useState(null);\n' +
    '  const [remoteStream, setRemoteStream] = useState(null);\n' +
    '  const [muted, setMuted] = useState(false);\n' +
    '  const [speakerOn, setSpeakerOn] = useState(false);\n' +
    '  const [cameraOff, setCameraOff] = useState(true);\n' +
    '  const [seconds, setSeconds] = useState(0);\n' +
    '  const pcRef = useRef(null);\n' +
    '  const localRef = useRef(null);\n' +
    '  const callRef = useRef(incomingCall || null);\n' +
    '  const seenCandidates = useRef(new Set());\n' +
    '  const closing = useRef(false);\n' +
    '  const acceptedAt = useRef(null);\n' +
    '\n' +
    '  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n' +
    '  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n' +
    '  // Unified call: preserve the proven v3.0 video-call transport for every call.\n' +
    '  const isVideo = true;\n' +
    '\n' +
    '  const closeMedia = () => {\n' +
    '    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n' +
    '    try { pcRef.current?.close?.(); } catch {}\n' +
    '    pcRef.current = null;\n' +
    '    localRef.current = null;\n' +
    '    setLocalStream(null);\n' +
    '    setRemoteStream(null);\n' +
    '    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n' +
    '  };\n' +
    '\n' +
    "  const finish = async (status = 'ended') => {\n" +
    '    if (closing.current) return;\n' +
    '    closing.current = true;\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    '      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n' +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closeMedia();\n' +
    '    onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const preparePeer = async currentCall => {\n' +
    '    if (pcRef.current) return pcRef.current;\n' +
    "    setStatusText('جارٍ تجهيز الميكروفون...');\n" +
    '    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n' +
    '    setSpeakerOn(false);\n' +
    '    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n' +
    "    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n" +
    '    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n' +
    '    localRef.current = stream;\n' +
    '    setLocalStream(stream);\n' +
    '    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n' +
    '    pcRef.current = pc;\n' +
    '    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n' +
    '    pc.ontrack = event => {\n' +
    '      const stream0 = event.streams?.[0];\n' +
    '      if (stream0) setRemoteStream(stream0);\n' +
    '    };\n' +
    '    pc.onicecandidate = event => {\n' +
    '      if (event.candidate && currentCall?.id) {\n' +
    '        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n' +
    '      }\n' +
    '    };\n' +
    '    pc.onconnectionstatechange = () => {\n' +
    '      const state = pc.connectionState;\n' +
    "      if (state === 'connected') {\n" +
    "        setPhase('connected');\n" +
    "        setStatusText('متصل');\n" +
    '        acceptedAt.current ||= Date.now();\n' +
    "      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n" +
    "      else if (state === 'failed') {\n" +
    "        setStatusText('تعذر الاتصال');\n" +
    "        finish('failed').catch(() => {});\n" +
    '      }\n' +
    '    };\n' +
    '    return pc;\n' +
    '  };\n' +
    '\n' +
    '  const outgoing = async () => {\n' +
    '    try {\n' +
    "      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n" +
    "      const row = await startCall(session, peerPhone, 'video');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      const pc = await preparePeer(row);\n' +
    "      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n" +
    '      const offer = await pc.createOffer();\n' +
    '      await pc.setLocalDescription(offer);\n' +
    '      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر بدء المكالمة');\n" +
    "      setTimeout(() => finish('failed'), 1300);\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  const reject = async () => {\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    "      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n" +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closing.current = true; closeMedia(); onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const accept = async () => {\n' +
    '    try {\n' +
    '      let row = callRef.current;\n' +
    "      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n" +
    "      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n" +
    '      const pc = await preparePeer(row);\n' +
    '      for (let i = 0; i < 20 && !row.offer; i++) {\n' +
    '        await sleep(350);\n' +
    '        row = await getCall(row.id);\n' +
    '      }\n' +
    "      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n' +
    '      const answer = await pc.createAnswer();\n' +
    '      await pc.setLocalDescription(answer);\n' +
    "      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n" +
    "      callRef.current = saved || { ...row, status: 'accepted', answer };\n" +
    '      setCallRow(callRef.current);\n' +
    '      acceptedAt.current = Date.now();\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر قبول المكالمة');\n" +
    "      await finish('failed');\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    closing.current = false;\n' +
    '    seenCandidates.current = new Set();\n' +
    '    acceptedAt.current = null;\n' +
    '    callRef.current = incomingCall || null;\n' +
    '    setCallRow(incomingCall || null);\n' +
    "    setPhase(incomingCall ? 'incoming' : 'starting');\n" +
    "    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '    setSeconds(0);\n' +
    '    setMuted(false);\n' +
    '    setCameraOff(true);\n' +
    '    setSpeakerOn(false);\n' +
    '    if (!incomingCall) outgoing();\n' +
    '    return () => { closeMedia(); };\n' +
    '    // one call lifecycle per mount/visible session\n' +
    '    // eslint-disable-next-line react-hooks/exhaustive-deps\n' +
    '  }, [visible, incomingCall?.id]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible || !callRow?.id) return;\n' +
    '    let alive = true;\n' +
    '    const poll = async () => {\n' +
    '      try {\n' +
    '        const row = await getCall(callRow.id);\n' +
    '        if (!alive || !row) return;\n' +
    '        callRef.current = row; setCallRow(row);\n' +
    "        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n" +
    "          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n" +
    '          closeMedia();\n' +
    '          setTimeout(() => { if (alive) onClose?.(); }, 650);\n' +
    '          return;\n' +
    '        }\n' +
    '        const pc = pcRef.current;\n' +
    '        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n' +
    '          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n' +
    '          acceptedAt.current ||= Date.now();\n' +
    "          setPhase('connected'); setStatusText('متصل');\n" +
    '        }\n' +
    '        if (pc) {\n' +
    '          const candidates = await getCallCandidates(row.id);\n' +
    '          for (const c of candidates || []) {\n' +
    '            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n' +
    '            // ICE candidates must wait until the remote description exists; otherwise\n' +
    '            // react-native-webrtc can reject them and the candidate would be lost.\n' +
    '            if (!pc.remoteDescription) continue;\n' +
    '            try {\n' +
    '              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n' +
    '              seenCandidates.current.add(c.id);\n' +
    '            } catch {}\n' +
    '          }\n' +
    '        }\n' +
    '      } catch {}\n' +
    '    };\n' +
    '    poll();\n' +
    '    const id = setInterval(poll, 700);\n' +
    '    return () => { alive = false; clearInterval(id); };\n' +
    '  }, [visible, callRow?.id, session.phone]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    const id = setInterval(() => {\n' +
    '      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n' +
    '    }, 1000);\n' +
    '    return () => clearInterval(id);\n' +
    '  }, [visible]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    "    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n" +
    '    const id = setTimeout(async () => {\n' +
    '      const row = await getCall(callRow.id).catch(() => null);\n' +
    "      if (row?.status === 'ringing') {\n" +
    "        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n" +
    "        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n" +
    '        await clearCallCandidates(row.id).catch(() => {});\n' +
    "        setStatusText('لم يتم الرد');\n" +
    '        setTimeout(() => onClose?.(), 800);\n' +
    '      }\n' +
    '    }, 45000);\n' +
    '    return () => clearTimeout(id);\n' +
    '  }, [visible, callRow?.id, incomingCall, phase]);\n' +
    '\n' +
    '  const toggleMute = () => {\n' +
    '    const next = !muted; setMuted(next);\n' +
    '    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n' +
    '  };\n' +
    '  const toggleSpeaker = async () => {\n' +
    '    const next = !s'... 3663 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/call-network-regression.test.mjs:16:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: `import React, { useEffect, useRef, useState } from 'react';\nimport { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\nimport { SafeAreaView } from 'react-native-safe-area-context';\nimport { Ionicons } from '@expo/vector-icons';\nimport { setAudioModeAsync } from 'expo-audio';\nimport {\n  RTCPeerConnection,\n  RTCIceCandidate,\n  RTCSessionDescription,\n  RTCView,\n  mediaDevices,\n} from 'react-native-webrtc';\nimport { ICE_SERVERS, fallbackName, nowIso } from './core';\nimport { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction IconButton({ icon, onPress, active, danger, disabled }) {\n  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n    <Ionicons name={icon} size={26} color="#fff" />\n  </Pressable>;\n}\n\nexport default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n  const [callRow, setCallRow] = useState(incomingCall || null);\n  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n  const [localStream, setLocalStream] = useState(null);\n  const [remoteStream, setRemoteStream] = useState(null);\n  const [muted, setMuted] = useState(false);\n  const [speakerOn, setSpeakerOn] = useState(false);\n  const [cameraOff, setCameraOff] = useState(true);\n  const [seconds, setSeconds] = useState(0);\n  const pcRef = useRef(null);\n  const localRef = useRef(null);\n  const callRef = useRef(incomingCall || null);\n  const seenCandidates = useRef(new Set());\n  const closing = useRef(false);\n  const acceptedAt = useRef(null);\n\n  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n  // Unified call: preserve the proven v3.0 video-call transport for every call.\n  const isVideo = true;\n\n  const closeMedia = () => {\n    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n    try { pcRef.current?.close?.(); } catch {}\n    pcRef.current = null;\n    localRef.current = null;\n    setLocalStream(null);\n    setRemoteStream(null);\n    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n  };\n\n  const finish = async (status = 'ended') => {\n    if (closing.current) return;\n    closing.current = true;\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closeMedia();\n    onClose?.();\n  };\n\n  const preparePeer = async currentCall => {\n    if (pcRef.current) return pcRef.current;\n    setStatusText('جارٍ تجهيز الميكروفون...');\n    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n    setSpeakerOn(false);\n    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n    localRef.current = stream;\n    setLocalStream(stream);\n    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n    pcRef.current = pc;\n    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n    pc.ontrack = event => {\n      const stream0 = event.streams?.[0];\n      if (stream0) setRemoteStream(stream0);\n    };\n    pc.onicecandidate = event => {\n      if (event.candidate && currentCall?.id) {\n        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n      }\n    };\n    pc.onconnectionstatechange = () => {\n      const state = pc.connectionState;\n      if (state === 'connected') {\n        setPhase('connected');\n        setStatusText('متصل');\n        acceptedAt.current ||= Date.now();\n      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n      else if (state === 'failed') {\n        setStatusText('تعذر الاتصال');\n        finish('failed').catch(() => {});\n      }\n    };\n    return pc;\n  };\n\n  const outgoing = async () => {\n    try {\n      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n      const row = await startCall(session, peerPhone, 'video');\n      callRef.current = row; setCallRow(row);\n      const pc = await preparePeer(row);\n      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n      const offer = await pc.createOffer();\n      await pc.setLocalDescription(offer);\n      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر بدء المكالمة');\n      setTimeout(() => finish('failed'), 1300);\n    }\n  };\n\n  const reject = async () => {\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closing.current = true; closeMedia(); onClose?.();\n  };\n\n  const accept = async () => {\n    try {\n      let row = callRef.current;\n      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n      const pc = await preparePeer(row);\n      for (let i = 0; i < 20 && !row.offer; i++) {\n        await sleep(350);\n        row = await getCall(row.id);\n      }\n      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n      callRef.current = row; setCallRow(row);\n      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n      const answer = await pc.createAnswer();\n      await pc.setLocalDescription(answer);\n      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n      callRef.current = saved || { ...row, status: 'accepted', answer };\n      setCallRow(callRef.current);\n      acceptedAt.current = Date.now();\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر قبول المكالمة');\n      await finish('failed');\n    }\n  };\n\n  useEffect(() => {\n    if (!visible) return;\n    closing.current = false;\n    seenCandidates.current = new Set();\n    acceptedAt.current = null;\n    callRef.current = incomingCall || null;\n    setCallRow(incomingCall || null);\n    setPhase(incomingCall ? 'incoming' : 'starting');\n    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n    setSeconds(0);\n    setMuted(false);\n    setCameraOff(true);\n    setSpeakerOn(false);\n    if (!incomingCall) outgoing();\n    return () => { closeMedia(); };\n    // one call lifecycle per mount/visible session\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [visible, incomingCall?.id]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id) return;\n    let alive = true;\n    const poll = async () => {\n      try {\n        const row = await getCall(callRow.id);\n        if (!alive || !row) return;\n        callRef.current = row; setCallRow(row);\n        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n          closeMedia();\n          setTimeout(() => { if (alive) onClose?.(); }, 650);\n          return;\n        }\n        const pc = pcRef.current;\n        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n          acceptedAt.current ||= Date.now();\n          setPhase('connected'); setStatusText('متصل');\n        }\n        if (pc) {\n          const candidates = await getCallCandidates(row.id);\n          for (const c of candidates || []) {\n            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n            // ICE candidates must wait until the remote description exists; otherwise\n            // react-native-webrtc can reject them and the candidate would be lost.\n            if (!pc.remoteDescription) continue;\n            try {\n              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n              seenCandidates.current.add(c.id);\n            } catch {}\n          }\n        }\n      } catch {}\n    };\n    poll();\n    const id = setInterval(poll, 700);\n    return () => { alive = false; clearInterval(id); };\n  }, [visible, callRow?.id, session.phone]);\n\n  useEffect(() => {\n    if (!visible) return;\n    const id = setInterval(() => {\n      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n    }, 1000);\n    return () => clearInterval(id);\n  }, [visible]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n    const id = setTimeout(async () => {\n      const row = await getCall(callRow.id).catch(() => null);\n      if (row?.status === 'ringing') {\n        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n        await clearCallCandidates(row.id).catch(() => {});\n        setStatusText('لم يتم الرد');\n        setTimeout(() => onClose?.(), 800);\n      }\n    }, 45000);\n    return () => clearTimeout(id);\n  }, [visible, callRow?.id, incomingCall, phase]);\n\n  const toggleMute = () => {\n    const next = !muted; setMuted(next);\n    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n  };\n  const toggleSpeaker = async () => {\n    const next = !s`... 3663 more characters,
    expected: /acquireMedia\(\{ audio: true, video: false \}/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/unified-call-final.test.mjs:7:1
✖ unified call starts audio-only and negotiates dormant video before offer (2.0125ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /acquireMedia\(\{ audio: true, video: false \}/. Input:
  
  "import React, { useEffect, useRef, useState } from 'react';\n" +
    "import { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\n" +
    "import { SafeAreaView } from 'react-native-safe-area-context';\n" +
    "import { Ionicons } from '@expo/vector-icons';\n" +
    "import { setAudioModeAsync } from 'expo-audio';\n" +
    'import {\n' +
    '  RTCPeerConnection,\n' +
    '  RTCIceCandidate,\n' +
    '  RTCSessionDescription,\n' +
    '  RTCView,\n' +
    '  mediaDevices,\n' +
    "} from 'react-native-webrtc';\n" +
    "import { ICE_SERVERS, fallbackName, nowIso } from './core';\n" +
    "import { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n" +
    '\n' +
    'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n' +
    '\n' +
    'function IconButton({ icon, onPress, active, danger, disabled }) {\n' +
    '  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n' +
    '    <Ionicons name={icon} size={26} color="#fff" />\n' +
    '  </Pressable>;\n' +
    '}\n' +
    '\n' +
    "export default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n" +
    '  const [callRow, setCallRow] = useState(incomingCall || null);\n' +
    "  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n" +
    "  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '  const [localStream, setLocalStream] = useState(null);\n' +
    '  const [remoteStream, setRemoteStream] = useState(null);\n' +
    '  const [muted, setMuted] = useState(false);\n' +
    '  const [speakerOn, setSpeakerOn] = useState(false);\n' +
    '  const [cameraOff, setCameraOff] = useState(true);\n' +
    '  const [seconds, setSeconds] = useState(0);\n' +
    '  const pcRef = useRef(null);\n' +
    '  const localRef = useRef(null);\n' +
    '  const callRef = useRef(incomingCall || null);\n' +
    '  const seenCandidates = useRef(new Set());\n' +
    '  const closing = useRef(false);\n' +
    '  const acceptedAt = useRef(null);\n' +
    '\n' +
    '  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n' +
    '  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n' +
    '  // Unified call: preserve the proven v3.0 video-call transport for every call.\n' +
    '  const isVideo = true;\n' +
    '\n' +
    '  const closeMedia = () => {\n' +
    '    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n' +
    '    try { pcRef.current?.close?.(); } catch {}\n' +
    '    pcRef.current = null;\n' +
    '    localRef.current = null;\n' +
    '    setLocalStream(null);\n' +
    '    setRemoteStream(null);\n' +
    '    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n' +
    '  };\n' +
    '\n' +
    "  const finish = async (status = 'ended') => {\n" +
    '    if (closing.current) return;\n' +
    '    closing.current = true;\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    '      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n' +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closeMedia();\n' +
    '    onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const preparePeer = async currentCall => {\n' +
    '    if (pcRef.current) return pcRef.current;\n' +
    "    setStatusText('جارٍ تجهيز الميكروفون...');\n" +
    '    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n' +
    '    setSpeakerOn(false);\n' +
    '    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n' +
    "    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n" +
    '    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n' +
    '    localRef.current = stream;\n' +
    '    setLocalStream(stream);\n' +
    '    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n' +
    '    pcRef.current = pc;\n' +
    '    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n' +
    '    pc.ontrack = event => {\n' +
    '      const stream0 = event.streams?.[0];\n' +
    '      if (stream0) setRemoteStream(stream0);\n' +
    '    };\n' +
    '    pc.onicecandidate = event => {\n' +
    '      if (event.candidate && currentCall?.id) {\n' +
    '        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n' +
    '      }\n' +
    '    };\n' +
    '    pc.onconnectionstatechange = () => {\n' +
    '      const state = pc.connectionState;\n' +
    "      if (state === 'connected') {\n" +
    "        setPhase('connected');\n" +
    "        setStatusText('متصل');\n" +
    '        acceptedAt.current ||= Date.now();\n' +
    "      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n" +
    "      else if (state === 'failed') {\n" +
    "        setStatusText('تعذر الاتصال');\n" +
    "        finish('failed').catch(() => {});\n" +
    '      }\n' +
    '    };\n' +
    '    return pc;\n' +
    '  };\n' +
    '\n' +
    '  const outgoing = async () => {\n' +
    '    try {\n' +
    "      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n" +
    "      const row = await startCall(session, peerPhone, 'video');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      const pc = await preparePeer(row);\n' +
    "      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n" +
    '      const offer = await pc.createOffer();\n' +
    '      await pc.setLocalDescription(offer);\n' +
    '      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر بدء المكالمة');\n" +
    "      setTimeout(() => finish('failed'), 1300);\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  const reject = async () => {\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    "      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n" +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closing.current = true; closeMedia(); onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const accept = async () => {\n' +
    '    try {\n' +
    '      let row = callRef.current;\n' +
    "      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n" +
    "      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n" +
    '      const pc = await preparePeer(row);\n' +
    '      for (let i = 0; i < 20 && !row.offer; i++) {\n' +
    '        await sleep(350);\n' +
    '        row = await getCall(row.id);\n' +
    '      }\n' +
    "      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n' +
    '      const answer = await pc.createAnswer();\n' +
    '      await pc.setLocalDescription(answer);\n' +
    "      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n" +
    "      callRef.current = saved || { ...row, status: 'accepted', answer };\n" +
    '      setCallRow(callRef.current);\n' +
    '      acceptedAt.current = Date.now();\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر قبول المكالمة');\n" +
    "      await finish('failed');\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    closing.current = false;\n' +
    '    seenCandidates.current = new Set();\n' +
    '    acceptedAt.current = null;\n' +
    '    callRef.current = incomingCall || null;\n' +
    '    setCallRow(incomingCall || null);\n' +
    "    setPhase(incomingCall ? 'incoming' : 'starting');\n" +
    "    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '    setSeconds(0);\n' +
    '    setMuted(false);\n' +
    '    setCameraOff(true);\n' +
    '    setSpeakerOn(false);\n' +
    '    if (!incomingCall) outgoing();\n' +
    '    return () => { closeMedia(); };\n' +
    '    // one call lifecycle per mount/visible session\n' +
    '    // eslint-disable-next-line react-hooks/exhaustive-deps\n' +
    '  }, [visible, incomingCall?.id]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible || !callRow?.id) return;\n' +
    '    let alive = true;\n' +
    '    const poll = async () => {\n' +
    '      try {\n' +
    '        const row = await getCall(callRow.id);\n' +
    '        if (!alive || !row) return;\n' +
    '        callRef.current = row; setCallRow(row);\n' +
    "        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n" +
    "          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n" +
    '          closeMedia();\n' +
    '          setTimeout(() => { if (alive) onClose?.(); }, 650);\n' +
    '          return;\n' +
    '        }\n' +
    '        const pc = pcRef.current;\n' +
    '        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n' +
    '          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n' +
    '          acceptedAt.current ||= Date.now();\n' +
    "          setPhase('connected'); setStatusText('متصل');\n" +
    '        }\n' +
    '        if (pc) {\n' +
    '          const candidates = await getCallCandidates(row.id);\n' +
    '          for (const c of candidates || []) {\n' +
    '            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n' +
    '            // ICE candidates must wait until the remote description exists; otherwise\n' +
    '            // react-native-webrtc can reject them and the candidate would be lost.\n' +
    '            if (!pc.remoteDescription) continue;\n' +
    '            try {\n' +
    '              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n' +
    '              seenCandidates.current.add(c.id);\n' +
    '            } catch {}\n' +
    '          }\n' +
    '        }\n' +
    '      } catch {}\n' +
    '    };\n' +
    '    poll();\n' +
    '    const id = setInterval(poll, 700);\n' +
    '    return () => { alive = false; clearInterval(id); };\n' +
    '  }, [visible, callRow?.id, session.phone]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    const id = setInterval(() => {\n' +
    '      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n' +
    '    }, 1000);\n' +
    '    return () => clearInterval(id);\n' +
    '  }, [visible]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    "    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n" +
    '    const id = setTimeout(async () => {\n' +
    '      const row = await getCall(callRow.id).catch(() => null);\n' +
    "      if (row?.status === 'ringing') {\n" +
    "        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n" +
    "        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n" +
    '        await clearCallCandidates(row.id).catch(() => {});\n' +
    "        setStatusText('لم يتم الرد');\n" +
    '        setTimeout(() => onClose?.(), 800);\n' +
    '      }\n' +
    '    }, 45000);\n' +
    '    return () => clearTimeout(id);\n' +
    '  }, [visible, callRow?.id, incomingCall, phase]);\n' +
    '\n' +
    '  const toggleMute = () => {\n' +
    '    const next = !muted; setMuted(next);\n' +
    '    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n' +
    '  };\n' +
    '  const toggleSpeaker = async () => {\n' +
    '    const next = !s'... 3663 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/unified-call-final.test.mjs:8:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.start (node:internal/test_runner/test:1242:17)
      at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: `import React, { useEffect, useRef, useState } from 'react';\nimport { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\nimport { SafeAreaView } from 'react-native-safe-area-context';\nimport { Ionicons } from '@expo/vector-icons';\nimport { setAudioModeAsync } from 'expo-audio';\nimport {\n  RTCPeerConnection,\n  RTCIceCandidate,\n  RTCSessionDescription,\n  RTCView,\n  mediaDevices,\n} from 'react-native-webrtc';\nimport { ICE_SERVERS, fallbackName, nowIso } from './core';\nimport { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction IconButton({ icon, onPress, active, danger, disabled }) {\n  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n    <Ionicons name={icon} size={26} color="#fff" />\n  </Pressable>;\n}\n\nexport default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n  const [callRow, setCallRow] = useState(incomingCall || null);\n  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n  const [localStream, setLocalStream] = useState(null);\n  const [remoteStream, setRemoteStream] = useState(null);\n  const [muted, setMuted] = useState(false);\n  const [speakerOn, setSpeakerOn] = useState(false);\n  const [cameraOff, setCameraOff] = useState(true);\n  const [seconds, setSeconds] = useState(0);\n  const pcRef = useRef(null);\n  const localRef = useRef(null);\n  const callRef = useRef(incomingCall || null);\n  const seenCandidates = useRef(new Set());\n  const closing = useRef(false);\n  const acceptedAt = useRef(null);\n\n  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n  // Unified call: preserve the proven v3.0 video-call transport for every call.\n  const isVideo = true;\n\n  const closeMedia = () => {\n    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n    try { pcRef.current?.close?.(); } catch {}\n    pcRef.current = null;\n    localRef.current = null;\n    setLocalStream(null);\n    setRemoteStream(null);\n    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n  };\n\n  const finish = async (status = 'ended') => {\n    if (closing.current) return;\n    closing.current = true;\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closeMedia();\n    onClose?.();\n  };\n\n  const preparePeer = async currentCall => {\n    if (pcRef.current) return pcRef.current;\n    setStatusText('جارٍ تجهيز الميكروفون...');\n    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n    setSpeakerOn(false);\n    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n    localRef.current = stream;\n    setLocalStream(stream);\n    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n    pcRef.current = pc;\n    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n    pc.ontrack = event => {\n      const stream0 = event.streams?.[0];\n      if (stream0) setRemoteStream(stream0);\n    };\n    pc.onicecandidate = event => {\n      if (event.candidate && currentCall?.id) {\n        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n      }\n    };\n    pc.onconnectionstatechange = () => {\n      const state = pc.connectionState;\n      if (state === 'connected') {\n        setPhase('connected');\n        setStatusText('متصل');\n        acceptedAt.current ||= Date.now();\n      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n      else if (state === 'failed') {\n        setStatusText('تعذر الاتصال');\n        finish('failed').catch(() => {});\n      }\n    };\n    return pc;\n  };\n\n  const outgoing = async () => {\n    try {\n      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n      const row = await startCall(session, peerPhone, 'video');\n      callRef.current = row; setCallRow(row);\n      const pc = await preparePeer(row);\n      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n      const offer = await pc.createOffer();\n      await pc.setLocalDescription(offer);\n      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر بدء المكالمة');\n      setTimeout(() => finish('failed'), 1300);\n    }\n  };\n\n  const reject = async () => {\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closing.current = true; closeMedia(); onClose?.();\n  };\n\n  const accept = async () => {\n    try {\n      let row = callRef.current;\n      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n      const pc = await preparePeer(row);\n      for (let i = 0; i < 20 && !row.offer; i++) {\n        await sleep(350);\n        row = await getCall(row.id);\n      }\n      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n      callRef.current = row; setCallRow(row);\n      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n      const answer = await pc.createAnswer();\n      await pc.setLocalDescription(answer);\n      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n      callRef.current = saved || { ...row, status: 'accepted', answer };\n      setCallRow(callRef.current);\n      acceptedAt.current = Date.now();\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر قبول المكالمة');\n      await finish('failed');\n    }\n  };\n\n  useEffect(() => {\n    if (!visible) return;\n    closing.current = false;\n    seenCandidates.current = new Set();\n    acceptedAt.current = null;\n    callRef.current = incomingCall || null;\n    setCallRow(incomingCall || null);\n    setPhase(incomingCall ? 'incoming' : 'starting');\n    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n    setSeconds(0);\n    setMuted(false);\n    setCameraOff(true);\n    setSpeakerOn(false);\n    if (!incomingCall) outgoing();\n    return () => { closeMedia(); };\n    // one call lifecycle per mount/visible session\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [visible, incomingCall?.id]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id) return;\n    let alive = true;\n    const poll = async () => {\n      try {\n        const row = await getCall(callRow.id);\n        if (!alive || !row) return;\n        callRef.current = row; setCallRow(row);\n        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n          closeMedia();\n          setTimeout(() => { if (alive) onClose?.(); }, 650);\n          return;\n        }\n        const pc = pcRef.current;\n        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n          acceptedAt.current ||= Date.now();\n          setPhase('connected'); setStatusText('متصل');\n        }\n        if (pc) {\n          const candidates = await getCallCandidates(row.id);\n          for (const c of candidates || []) {\n            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n            // ICE candidates must wait until the remote description exists; otherwise\n            // react-native-webrtc can reject them and the candidate would be lost.\n            if (!pc.remoteDescription) continue;\n            try {\n              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n              seenCandidates.current.add(c.id);\n            } catch {}\n          }\n        }\n      } catch {}\n    };\n    poll();\n    const id = setInterval(poll, 700);\n    return () => { alive = false; clearInterval(id); };\n  }, [visible, callRow?.id, session.phone]);\n\n  useEffect(() => {\n    if (!visible) return;\n    const id = setInterval(() => {\n      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n    }, 1000);\n    return () => clearInterval(id);\n  }, [visible]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n    const id = setTimeout(async () => {\n      const row = await getCall(callRow.id).catch(() => null);\n      if (row?.status === 'ringing') {\n        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n        await clearCallCandidates(row.id).catch(() => {});\n        setStatusText('لم يتم الرد');\n        setTimeout(() => onClose?.(), 800);\n      }\n    }, 45000);\n    return () => clearTimeout(id);\n  }, [visible, callRow?.id, incomingCall, phase]);\n\n  const toggleMute = () => {\n    const next = !muted; setMuted(next);\n    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n  };\n  const toggleSpeaker = async () => {\n    const next = !s`... 3663 more characters,
    expected: /acquireMedia\(\{ audio: true, video: false \}/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/unified-call-final.test.mjs:14:1
✖ camera is attached on demand to the pre-negotiated video sender (0.639648ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /acquireMedia\(\{ audio: false, video: \{ facingMode: 'user' \} \}/. Input:
  
  "import React, { useEffect, useRef, useState } from 'react';\n" +
    "import { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\n" +
    "import { SafeAreaView } from 'react-native-safe-area-context';\n" +
    "import { Ionicons } from '@expo/vector-icons';\n" +
    "import { setAudioModeAsync } from 'expo-audio';\n" +
    'import {\n' +
    '  RTCPeerConnection,\n' +
    '  RTCIceCandidate,\n' +
    '  RTCSessionDescription,\n' +
    '  RTCView,\n' +
    '  mediaDevices,\n' +
    "} from 'react-native-webrtc';\n" +
    "import { ICE_SERVERS, fallbackName, nowIso } from './core';\n" +
    "import { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n" +
    '\n' +
    'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n' +
    '\n' +
    'function IconButton({ icon, onPress, active, danger, disabled }) {\n' +
    '  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n' +
    '    <Ionicons name={icon} size={26} color="#fff" />\n' +
    '  </Pressable>;\n' +
    '}\n' +
    '\n' +
    "export default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n" +
    '  const [callRow, setCallRow] = useState(incomingCall || null);\n' +
    "  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n" +
    "  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '  const [localStream, setLocalStream] = useState(null);\n' +
    '  const [remoteStream, setRemoteStream] = useState(null);\n' +
    '  const [muted, setMuted] = useState(false);\n' +
    '  const [speakerOn, setSpeakerOn] = useState(false);\n' +
    '  const [cameraOff, setCameraOff] = useState(true);\n' +
    '  const [seconds, setSeconds] = useState(0);\n' +
    '  const pcRef = useRef(null);\n' +
    '  const localRef = useRef(null);\n' +
    '  const callRef = useRef(incomingCall || null);\n' +
    '  const seenCandidates = useRef(new Set());\n' +
    '  const closing = useRef(false);\n' +
    '  const acceptedAt = useRef(null);\n' +
    '\n' +
    '  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n' +
    '  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n' +
    '  // Unified call: preserve the proven v3.0 video-call transport for every call.\n' +
    '  const isVideo = true;\n' +
    '\n' +
    '  const closeMedia = () => {\n' +
    '    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n' +
    '    try { pcRef.current?.close?.(); } catch {}\n' +
    '    pcRef.current = null;\n' +
    '    localRef.current = null;\n' +
    '    setLocalStream(null);\n' +
    '    setRemoteStream(null);\n' +
    '    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n' +
    '  };\n' +
    '\n' +
    "  const finish = async (status = 'ended') => {\n" +
    '    if (closing.current) return;\n' +
    '    closing.current = true;\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    '      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n' +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closeMedia();\n' +
    '    onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const preparePeer = async currentCall => {\n' +
    '    if (pcRef.current) return pcRef.current;\n' +
    "    setStatusText('جارٍ تجهيز الميكروفون...');\n" +
    '    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n' +
    '    setSpeakerOn(false);\n' +
    '    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n' +
    "    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n" +
    '    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n' +
    '    localRef.current = stream;\n' +
    '    setLocalStream(stream);\n' +
    '    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n' +
    '    pcRef.current = pc;\n' +
    '    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n' +
    '    pc.ontrack = event => {\n' +
    '      const stream0 = event.streams?.[0];\n' +
    '      if (stream0) setRemoteStream(stream0);\n' +
    '    };\n' +
    '    pc.onicecandidate = event => {\n' +
    '      if (event.candidate && currentCall?.id) {\n' +
    '        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n' +
    '      }\n' +
    '    };\n' +
    '    pc.onconnectionstatechange = () => {\n' +
    '      const state = pc.connectionState;\n' +
    "      if (state === 'connected') {\n" +
    "        setPhase('connected');\n" +
    "        setStatusText('متصل');\n" +
    '        acceptedAt.current ||= Date.now();\n' +
    "      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n" +
    "      else if (state === 'failed') {\n" +
    "        setStatusText('تعذر الاتصال');\n" +
    "        finish('failed').catch(() => {});\n" +
    '      }\n' +
    '    };\n' +
    '    return pc;\n' +
    '  };\n' +
    '\n' +
    '  const outgoing = async () => {\n' +
    '    try {\n' +
    "      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n" +
    "      const row = await startCall(session, peerPhone, 'video');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      const pc = await preparePeer(row);\n' +
    "      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n" +
    '      const offer = await pc.createOffer();\n' +
    '      await pc.setLocalDescription(offer);\n' +
    '      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر بدء المكالمة');\n" +
    "      setTimeout(() => finish('failed'), 1300);\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  const reject = async () => {\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    "      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n" +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closing.current = true; closeMedia(); onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const accept = async () => {\n' +
    '    try {\n' +
    '      let row = callRef.current;\n' +
    "      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n" +
    "      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n" +
    '      const pc = await preparePeer(row);\n' +
    '      for (let i = 0; i < 20 && !row.offer; i++) {\n' +
    '        await sleep(350);\n' +
    '        row = await getCall(row.id);\n' +
    '      }\n' +
    "      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n' +
    '      const answer = await pc.createAnswer();\n' +
    '      await pc.setLocalDescription(answer);\n' +
    "      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n" +
    "      callRef.current = saved || { ...row, status: 'accepted', answer };\n" +
    '      setCallRow(callRef.current);\n' +
    '      acceptedAt.current = Date.now();\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر قبول المكالمة');\n" +
    "      await finish('failed');\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    closing.current = false;\n' +
    '    seenCandidates.current = new Set();\n' +
    '    acceptedAt.current = null;\n' +
    '    callRef.current = incomingCall || null;\n' +
    '    setCallRow(incomingCall || null);\n' +
    "    setPhase(incomingCall ? 'incoming' : 'starting');\n" +
    "    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '    setSeconds(0);\n' +
    '    setMuted(false);\n' +
    '    setCameraOff(true);\n' +
    '    setSpeakerOn(false);\n' +
    '    if (!incomingCall) outgoing();\n' +
    '    return () => { closeMedia(); };\n' +
    '    // one call lifecycle per mount/visible session\n' +
    '    // eslint-disable-next-line react-hooks/exhaustive-deps\n' +
    '  }, [visible, incomingCall?.id]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible || !callRow?.id) return;\n' +
    '    let alive = true;\n' +
    '    const poll = async () => {\n' +
    '      try {\n' +
    '        const row = await getCall(callRow.id);\n' +
    '        if (!alive || !row) return;\n' +
    '        callRef.current = row; setCallRow(row);\n' +
    "        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n" +
    "          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n" +
    '          closeMedia();\n' +
    '          setTimeout(() => { if (alive) onClose?.(); }, 650);\n' +
    '          return;\n' +
    '        }\n' +
    '        const pc = pcRef.current;\n' +
    '        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n' +
    '          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n' +
    '          acceptedAt.current ||= Date.now();\n' +
    "          setPhase('connected'); setStatusText('متصل');\n" +
    '        }\n' +
    '        if (pc) {\n' +
    '          const candidates = await getCallCandidates(row.id);\n' +
    '          for (const c of candidates || []) {\n' +
    '            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n' +
    '            // ICE candidates must wait until the remote description exists; otherwise\n' +
    '            // react-native-webrtc can reject them and the candidate would be lost.\n' +
    '            if (!pc.remoteDescription) continue;\n' +
    '            try {\n' +
    '              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n' +
    '              seenCandidates.current.add(c.id);\n' +
    '            } catch {}\n' +
    '          }\n' +
    '        }\n' +
    '      } catch {}\n' +
    '    };\n' +
    '    poll();\n' +
    '    const id = setInterval(poll, 700);\n' +
    '    return () => { alive = false; clearInterval(id); };\n' +
    '  }, [visible, callRow?.id, session.phone]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    const id = setInterval(() => {\n' +
    '      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n' +
    '    }, 1000);\n' +
    '    return () => clearInterval(id);\n' +
    '  }, [visible]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    "    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n" +
    '    const id = setTimeout(async () => {\n' +
    '      const row = await getCall(callRow.id).catch(() => null);\n' +
    "      if (row?.status === 'ringing') {\n" +
    "        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n" +
    "        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n" +
    '        await clearCallCandidates(row.id).catch(() => {});\n' +
    "        setStatusText('لم يتم الرد');\n" +
    '        setTimeout(() => onClose?.(), 800);\n' +
    '      }\n' +
    '    }, 45000);\n' +
    '    return () => clearTimeout(id);\n' +
    '  }, [visible, callRow?.id, incomingCall, phase]);\n' +
    '\n' +
    '  const toggleMute = () => {\n' +
    '    const next = !muted; setMuted(next);\n' +
    '    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n' +
    '  };\n' +
    '  const toggleSpeaker = async () => {\n' +
    '    const next = !s'... 3663 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/unified-call-final.test.mjs:15:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: `import React, { useEffect, useRef, useState } from 'react';\nimport { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\nimport { SafeAreaView } from 'react-native-safe-area-context';\nimport { Ionicons } from '@expo/vector-icons';\nimport { setAudioModeAsync } from 'expo-audio';\nimport {\n  RTCPeerConnection,\n  RTCIceCandidate,\n  RTCSessionDescription,\n  RTCView,\n  mediaDevices,\n} from 'react-native-webrtc';\nimport { ICE_SERVERS, fallbackName, nowIso } from './core';\nimport { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction IconButton({ icon, onPress, active, danger, disabled }) {\n  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n    <Ionicons name={icon} size={26} color="#fff" />\n  </Pressable>;\n}\n\nexport default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n  const [callRow, setCallRow] = useState(incomingCall || null);\n  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n  const [localStream, setLocalStream] = useState(null);\n  const [remoteStream, setRemoteStream] = useState(null);\n  const [muted, setMuted] = useState(false);\n  const [speakerOn, setSpeakerOn] = useState(false);\n  const [cameraOff, setCameraOff] = useState(true);\n  const [seconds, setSeconds] = useState(0);\n  const pcRef = useRef(null);\n  const localRef = useRef(null);\n  const callRef = useRef(incomingCall || null);\n  const seenCandidates = useRef(new Set());\n  const closing = useRef(false);\n  const acceptedAt = useRef(null);\n\n  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n  // Unified call: preserve the proven v3.0 video-call transport for every call.\n  const isVideo = true;\n\n  const closeMedia = () => {\n    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n    try { pcRef.current?.close?.(); } catch {}\n    pcRef.current = null;\n    localRef.current = null;\n    setLocalStream(null);\n    setRemoteStream(null);\n    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n  };\n\n  const finish = async (status = 'ended') => {\n    if (closing.current) return;\n    closing.current = true;\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closeMedia();\n    onClose?.();\n  };\n\n  const preparePeer = async currentCall => {\n    if (pcRef.current) return pcRef.current;\n    setStatusText('جارٍ تجهيز الميكروفون...');\n    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n    setSpeakerOn(false);\n    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n    localRef.current = stream;\n    setLocalStream(stream);\n    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n    pcRef.current = pc;\n    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n    pc.ontrack = event => {\n      const stream0 = event.streams?.[0];\n      if (stream0) setRemoteStream(stream0);\n    };\n    pc.onicecandidate = event => {\n      if (event.candidate && currentCall?.id) {\n        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n      }\n    };\n    pc.onconnectionstatechange = () => {\n      const state = pc.connectionState;\n      if (state === 'connected') {\n        setPhase('connected');\n        setStatusText('متصل');\n        acceptedAt.current ||= Date.now();\n      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n      else if (state === 'failed') {\n        setStatusText('تعذر الاتصال');\n        finish('failed').catch(() => {});\n      }\n    };\n    return pc;\n  };\n\n  const outgoing = async () => {\n    try {\n      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n      const row = await startCall(session, peerPhone, 'video');\n      callRef.current = row; setCallRow(row);\n      const pc = await preparePeer(row);\n      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n      const offer = await pc.createOffer();\n      await pc.setLocalDescription(offer);\n      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر بدء المكالمة');\n      setTimeout(() => finish('failed'), 1300);\n    }\n  };\n\n  const reject = async () => {\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closing.current = true; closeMedia(); onClose?.();\n  };\n\n  const accept = async () => {\n    try {\n      let row = callRef.current;\n      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n      const pc = await preparePeer(row);\n      for (let i = 0; i < 20 && !row.offer; i++) {\n        await sleep(350);\n        row = await getCall(row.id);\n      }\n      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n      callRef.current = row; setCallRow(row);\n      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n      const answer = await pc.createAnswer();\n      await pc.setLocalDescription(answer);\n      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n      callRef.current = saved || { ...row, status: 'accepted', answer };\n      setCallRow(callRef.current);\n      acceptedAt.current = Date.now();\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر قبول المكالمة');\n      await finish('failed');\n    }\n  };\n\n  useEffect(() => {\n    if (!visible) return;\n    closing.current = false;\n    seenCandidates.current = new Set();\n    acceptedAt.current = null;\n    callRef.current = incomingCall || null;\n    setCallRow(incomingCall || null);\n    setPhase(incomingCall ? 'incoming' : 'starting');\n    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n    setSeconds(0);\n    setMuted(false);\n    setCameraOff(true);\n    setSpeakerOn(false);\n    if (!incomingCall) outgoing();\n    return () => { closeMedia(); };\n    // one call lifecycle per mount/visible session\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [visible, incomingCall?.id]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id) return;\n    let alive = true;\n    const poll = async () => {\n      try {\n        const row = await getCall(callRow.id);\n        if (!alive || !row) return;\n        callRef.current = row; setCallRow(row);\n        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n          closeMedia();\n          setTimeout(() => { if (alive) onClose?.(); }, 650);\n          return;\n        }\n        const pc = pcRef.current;\n        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n          acceptedAt.current ||= Date.now();\n          setPhase('connected'); setStatusText('متصل');\n        }\n        if (pc) {\n          const candidates = await getCallCandidates(row.id);\n          for (const c of candidates || []) {\n            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n            // ICE candidates must wait until the remote description exists; otherwise\n            // react-native-webrtc can reject them and the candidate would be lost.\n            if (!pc.remoteDescription) continue;\n            try {\n              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n              seenCandidates.current.add(c.id);\n            } catch {}\n          }\n        }\n      } catch {}\n    };\n    poll();\n    const id = setInterval(poll, 700);\n    return () => { alive = false; clearInterval(id); };\n  }, [visible, callRow?.id, session.phone]);\n\n  useEffect(() => {\n    if (!visible) return;\n    const id = setInterval(() => {\n      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n    }, 1000);\n    return () => clearInterval(id);\n  }, [visible]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n    const id = setTimeout(async () => {\n      const row = await getCall(callRow.id).catch(() => null);\n      if (row?.status === 'ringing') {\n        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n        await clearCallCandidates(row.id).catch(() => {});\n        setStatusText('لم يتم الرد');\n        setTimeout(() => onClose?.(), 800);\n      }\n    }, 45000);\n    return () => clearTimeout(id);\n  }, [visible, callRow?.id, incomingCall, phase]);\n\n  const toggleMute = () => {\n    const next = !muted; setMuted(next);\n    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n  };\n  const toggleSpeaker = async () => {\n    const next = !s`... 3663 more characters,
    expected: /acquireMedia\(\{ audio: false, video: \{ facingMode: 'user' \} \}/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/v3-1-face-warp.test.mjs:28:1
✖ Android face warp owns a separate CameraX + ML Kit pipeline with frame dropping (1.960914ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /ImageAnalysis\.STRATEGY_KEEP_ONLY_LATEST/. Input:
  
  'package com.sawaup.facewarp\n' +
    '\n' +
    'import android.content.Context\n' +
    'import android.graphics.Bitmap\n' +
    'import android.graphics.SurfaceTexture\n' +
    'import android.view.TextureView\n' +
    'import android.view.ViewGroup\n' +
    'import android.os.Handler\n' +
    'import android.os.Looper\n' +
    'import androidx.core.content.ContextCompat\n' +
    'import androidx.lifecycle.DefaultLifecycleObserver\n' +
    'import androidx.lifecycle.LifecycleOwner\n' +
    'import java.lang.ref.WeakReference\n' +
    'import java.util.concurrent.ConcurrentHashMap\n' +
    'import com.google.mlkit.vision.common.InputImage\n' +
    'import com.google.mlkit.vision.face.Face\n' +
    'import com.google.mlkit.vision.face.FaceDetection\n' +
    'import com.google.mlkit.vision.face.FaceDetector\n' +
    'import com.google.mlkit.vision.face.FaceDetectorOptions\n' +
    'import com.google.mlkit.vision.face.FaceContour\n' +
    'import com.google.mlkit.vision.facemesh.FaceMesh\n' +
    'import com.google.mlkit.vision.facemesh.FaceMeshDetection\n' +
    'import com.google.mlkit.vision.facemesh.FaceMeshDetector\n' +
    'import com.google.mlkit.vision.facemesh.FaceMeshDetectorOptions\n' +
    'import expo.modules.kotlin.AppContext\n' +
    'import expo.modules.kotlin.viewevent.EventDispatcher\n' +
    'import expo.modules.kotlin.views.ExpoView\n' +
    'import java.util.concurrent.atomic.AtomicBoolean\n' +
    'import kotlin.math.PI\n' +
    'import kotlin.math.abs\n' +
    'import kotlin.math.atan2\n' +
    'import kotlin.math.max\n' +
    '\n' +
    '\n' +
    '/** Processing/rendering only. VisionCamera is the sole owner of the Camera device. */\n' +
    'class SawaFaceWarpView(context: Context, appContext: AppContext) :\n' +
    '  ExpoView(context,appContext), TextureView.SurfaceTextureListener, DefaultLifecycleObserver {\n' +
    '  val onReady by EventDispatcher()\n' +
    '  val onError by EventDispatcher()\n' +
    '  val onFaceState by EventDispatcher()\n' +
    '  private val main=Handler(Looper.getMainLooper())\n' +
    '  private val mainExecutor=ContextCompat.getMainExecutor(context)\n' +
    '  private val textureView=TextureView(context).also {\n' +
    '    it.isOpaque=false; it.alpha=0f; it.surfaceTextureListener=this\n' +
    '    addView(it,ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT))\n' +
    '  }\n' +
    '  @Volatile private var active=false\n' +
    '  @Volatile private var foreground=true\n' +
    '  @Volatile private var attached=false\n' +
    '  @Volatile private var filter="طبيعي"\n' +
    '  @Volatile private var facing="front"\n' +
    '  @Volatile private var generation=0\n' +
    '  @Volatile private var pixelWidth=1\n' +
    '  @Volatile private var pixelHeight=1\n' +
    '  private var sessionId=""\n' +
    '  private var ready=false\n' +
    '  private var renderer:WarpRenderer?=null\n' +
    '  private var rendererStopping=false\n' +
    '  private var meshDetector:FaceMeshDetector?=null\n' +
    '  private var fallbackDetector:FaceDetector?=null\n' +
    '  private val busy=AtomicBoolean(false)\n' +
    '  private var detectorInFlight=false\n' +
    '  private var owner:LifecycleOwner?=null\n' +
    '  private var errorReported=false\n' +
    '  private var renderFailed=false\n' +
    '\n' +
    '  internal fun wantsFrames()=active && foreground && attached && filter!="طبيعي" && !renderFailed\n' +
    '  internal fun reserveFrame():Int? = if(wantsFrames() && busy.compareAndSet(false,true))generation else null\n' +
    '  internal fun abandonFrame(){busy.set(false)}\n' +
    '  internal fun acceptFrame(bitmap:Bitmap,rotation:Int,crop:IntArray,token:Int){\n' +
    '    main.post {\n' +
    '      if(token!=generation || !wantsFrames()){bitmap.recycle();busy.set(false);return@post}\n' +
    '      val geometry=FrameGeometry.create(bitmap.width,bitmap.height,rotation,facing=="front",pixelWidth,pixelHeight,crop[0],crop[1],crop[2],crop[3])\n' +
    '      val image=InputImage.fromBitmap(bitmap,rotation)\n' +
    '      detectorInFlight=true\n' +
    '      fun finish(state:WarpFaceState) {\n' +
    '        detectorInFlight=false\n' +
    '        if(token!=generation || !wantsFrames()) {\n' +
    '          bitmap.recycle();busy.set(false);closeDetectorsIfIdle();return\n' +
    '        }\n' +
    '        emitFace(state,geometry)\n' +
    '        if(MORPH_FILTERS.contains(filter)) {\n' +
    '          val gl=ensureRenderer()\n' +
    '          if(gl==null){bitmap.recycle();busy.set(false);return}\n' +
    '          gl.draw(bitmap,geometry,state,filter){rendered->main.post{\n' +
    '            busy.set(false)\n' +
    '            if(rendered && token==generation && wantsFrames() && !ready){\n' +
    '              ready=true;textureView.alpha=1f\n' +
    '              onReady(mapOf("engine" to "vision-camera-frames","filter" to filter))\n' +
    '            }\n' +
    '          }}\n' +
    '        } else {bitmap.recycle();busy.set(false)}\n' +
    '      }\n' +
    '      fun fallback(){\n' +
    '        if(token!=generation || !wantsFrames()){finish(WarpFaceState());return}\n' +
    '        try {\n' +
    '          val detector=fallbackDetector ?: FaceDetection.getClient(FaceDetectorOptions.Builder()\n' +
    '            .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)\n' +
    '            .setContourMode(FaceDetectorOptions.CONTOUR_MODE_ALL).setMinFaceSize(.12f).build()).also{fallbackDetector=it}\n' +
    '          detector.process(image).addOnSuccessListener(mainExecutor){faces->\n' +
    '            val face=chooseFace(faces)\n' +
    '            val state=if(face!=null && isPoseSafe(face))fallbackFaceState(face,bitmap.width,bitmap.height,rotation) else WarpFaceState()\n' +
    '            finish(state)\n' +
    '          }.addOnFailureListener(mainExecutor){finish(WarpFaceState())}\n' +
    '        } catch(e:Exception){finish(WarpFaceState());report(e)}\n' +
    '      }\n' +
    '      if(!MORPH_FILTERS.contains(filter)){fallback();return@post}\n' +
    '      try {\n' +
    '        val detector=meshDetector ?: FaceMeshDetection.getClient(FaceMeshDetectorOptions.Builder().build()).also{meshDetector=it}\n' +
    '        detector.process(image).addOnSuccessListener(mainExecutor){meshes->\n' +
    '          if(token!=generation || !wantsFrames()){finish(WarpFaceState());return@addOnSuccessListener}\n' +
    '          val mesh=chooseMesh(meshes)\n' +
    '          if(mesh==null || !isMeshPoseSafe(mesh))fallback()\n' +
    '          else finish(meshFaceState(mesh,bitmap.width,bitmap.height,rotation))\n' +
    '        }.addOnFailureListener(mainExecutor){fallback()}\n' +
    '      } catch(e:Exception){fallback();report(e)}\n' +
    '    }\n' +
    '  }\n' +
    '  private fun emitFace(state:WarpFaceState,g:FrameGeometry){\n' +
    '    val a=g.uprightToDisplay(state.faceCenterX-state.faceRadiusX,state.faceCenterY-state.faceRadiusY)\n' +
    '    val b=g.uprightToDisplay(state.faceCenterX+state.faceRadiusX,state.faceCenterY+state.faceRadiusY)\n' +
    '    val density=resources.displayMetrics.density\n' +
    '    val bounds=mapOf("x" to minOf(a.first,b.first)*pixelWidth/density,"y" to minOf(a.second,b.second)*pixelHeight/density,\n' +
    '      "width" to abs(b.first-a.first)*pixelWidth/density,"height" to abs(b.second-a.second)*pixelHeight/density)\n' +
    '    onFaceState(mapOf("visible" to state.hasFace,"filter" to filter,"bounds" to bounds))\n' +
    '  }\n' +
    '  fun setSessionId(value:String){\n' +
    '    if(sessionId==value)return\n' +
    '    Registry.remove(sessionId,this);sessionId=value\n' +
    '    if(attached)Registry.put(value,this)\n' +
    '  }\n' +
    '  fun setFacing(value:String){if(facing!=value){facing=value;invalidateFrames()}}\n' +
    '  fun setFilter(value:String){if(filter!=value){filter=value;renderFailed=false;errorReported=false;invalidateFrames()}}\n' +
    '  fun setActive(value:Boolean){if(active!=value){active=value;invalidateFrames();if(!value)closeDetectorsIfIdle()}}\n' +
    '  private fun invalidateFrames(){\n' +
    '    ++generation;ready=false;textureView.alpha=0f\n' +
    '    // Leave the output connected. Filter changes do not create or stop a camera.\n' +
    '    if(filter=="طبيعي" || !active || !foreground) {\n' +
    '      closeDetectorsIfIdle()\n' +
    '      stopRenderer()\n' +
    '    }\n' +
    '  }\n' +
    '  private fun stopRenderer(){\n' +
    '    val gl=renderer ?: return\n' +
    '    if(rendererStopping)return\n' +
    '    rendererStopping=true\n' +
    '    gl.release{main.post{if(renderer===gl){renderer=null;rendererStopping=false}}}\n' +
    '  }\n' +
    '  private fun closeDetectorsIfIdle(){\n' +
    '    if(detectorInFlight)return\n' +
    '    meshDetector?.close();meshDetector=null\n' +
    '    fallbackDetector?.close();fallbackDetector=null\n' +
    '  }\n' +
    '  private fun ensureRenderer():WarpRenderer?{\n' +
    '    if(renderFailed || rendererStopping || !textureView.isAvailable)return null\n' +
    '    return renderer ?: WarpRenderer(textureView.surfaceTexture ?: return null){error->main.post{\n' +
    '      renderFailed=true;textureView.alpha=0f;ready=false;stopRenderer();report(error)\n' +
    '    }}.also{renderer=it}\n' +
    '  }\n' +
    '  private fun report(error:Throwable){\n' +
    '    if(!attached || errorReported)return\n' +
    '    errorReported=true\n' +
    '    android.util.Log.e("SawaFaceWarp","Frame processing failed",error)\n' +
    '    onError(mapOf("message" to "تعذر معالجة الفلتر. أعد اختيار الفلتر للمحاولة.","engine" to "vision-camera-frames"))\n' +
    '  }\n' +
    '  override fun onLayout(changed:Boolean,left:Int,top:Int,right:Int,bottom:Int){\n' +
    '    pixelWidth=max(1,right-left);pixelHeight=max(1,bottom-top)\n' +
    '    textureView.layout(0,0,pixelWidth,pixelHeight)\n' +
    '  }\n' +
    '  override fun onSurfaceTextureAvailable(surface:SurfaceTexture,width:Int,height:Int){}\n' +
    '  override fun onSurfaceTextureSizeChanged(surface:SurfaceTexture,width:Int,height:Int){invalidateFrames()}\n' +
    '  override fun onSurfaceTextureUpdated(surface:SurfaceTexture){}\n' +
    '  override fun onSurfaceTextureDestroyed(surface:SurfaceTexture):Boolean{\n' +
    '    ++generation;ready=false\n' +
    '    val gl=renderer;renderer=null;rendererStopping=false\n' +
    '    if(gl!=null)gl.release{surface.release()} else surface.release()\n' +
    '    return false // TextureView must NOT destroy a surface still used by the GL thread.\n' +
    '  }\n' +
    '  override fun onAttachedToWindow(){\n' +
    '    super.onAttachedToWindow();attached=true;Registry.put(sessionId,this)\n' +
    '    owner=appContext.currentActivity as? LifecycleOwner\n' +
    '    owner?.lifecycle?.addObserver(this)\n' +
    '  }\n' +
    '  override fun onDetachedFromWindow(){\n' +
    '    attached=false;Registry.remove(sessionId,this);++generation\n' +
    '    owner?.lifecycle?.removeObserver(this);owner=null\n' +
    '    closeDetectorsIfIdle()\n' +
    '    // Texture destruction callback owns EGL teardown and SurfaceTexture release.\n' +
    '    super.onDetachedFromWindow()\n' +
    '  }\n' +
    '  override fun onStop(owner:LifecycleOwner){foreground=false;invalidateFrames()}\n' +
    '  override fun onStart(owner:LifecycleOwner){foreground=true;invalidateFrames()}\n' +
    '\n' +
    '  private fun chooseMesh(meshes: List<FaceMesh>): FaceMesh? = meshes.maxByOrNull {\n' +
    '    it.boundingBox.width() * it.boundingBox.height()\n' +
    '  }\n' +
    '\n' +
    '  private fun chooseFace(faces: List<Face>): Face? = faces.maxByOrNull { it.boundingBox.width() * it.boundingBox.height() }\n' +
    '\n' +
    '  private fun isPoseSafe(face: Face): Boolean {\n' +
    '    // Extreme head poses make 2D warps look unstable. Disable deformation for\n' +
    '    // that frame and keep the untouched camera image instead.\n' +
    '    return abs(face.headEulerAngl'... 8866 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/v3-1-face-warp.test.mjs:35:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: 'package com.sawaup.facewarp\n\nimport android.content.Context\nimport android.graphics.Bitmap\nimport android.graphics.SurfaceTexture\nimport android.view.TextureView\nimport android.view.ViewGroup\nimport android.os.Handler\nimport android.os.Looper\nimport androidx.core.content.ContextCompat\nimport androidx.lifecycle.DefaultLifecycleObserver\nimport androidx.lifecycle.LifecycleOwner\nimport java.lang.ref.WeakReference\nimport java.util.concurrent.ConcurrentHashMap\nimport com.google.mlkit.vision.common.InputImage\nimport com.google.mlkit.vision.face.Face\nimport com.google.mlkit.vision.face.FaceDetection\nimport com.google.mlkit.vision.face.FaceDetector\nimport com.google.mlkit.vision.face.FaceDetectorOptions\nimport com.google.mlkit.vision.face.FaceContour\nimport com.google.mlkit.vision.facemesh.FaceMesh\nimport com.google.mlkit.vision.facemesh.FaceMeshDetection\nimport com.google.mlkit.vision.facemesh.FaceMeshDetector\nimport com.google.mlkit.vision.facemesh.FaceMeshDetectorOptions\nimport expo.modules.kotlin.AppContext\nimport expo.modules.kotlin.viewevent.EventDispatcher\nimport expo.modules.kotlin.views.ExpoView\nimport java.util.concurrent.atomic.AtomicBoolean\nimport kotlin.math.PI\nimport kotlin.math.abs\nimport kotlin.math.atan2\nimport kotlin.math.max\n\n\n/** Processing/rendering only. VisionCamera is the sole owner of the Camera device. */\nclass SawaFaceWarpView(context: Context, appContext: AppContext) :\n  ExpoView(context,appContext), TextureView.SurfaceTextureListener, DefaultLifecycleObserver {\n  val onReady by EventDispatcher()\n  val onError by EventDispatcher()\n  val onFaceState by EventDispatcher()\n  private val main=Handler(Looper.getMainLooper())\n  private val mainExecutor=ContextCompat.getMainExecutor(context)\n  private val textureView=TextureView(context).also {\n    it.isOpaque=false; it.alpha=0f; it.surfaceTextureListener=this\n    addView(it,ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT))\n  }\n  @Volatile private var active=false\n  @Volatile private var foreground=true\n  @Volatile private var attached=false\n  @Volatile private var filter="طبيعي"\n  @Volatile private var facing="front"\n  @Volatile private var generation=0\n  @Volatile private var pixelWidth=1\n  @Volatile private var pixelHeight=1\n  private var sessionId=""\n  private var ready=false\n  private var renderer:WarpRenderer?=null\n  private var rendererStopping=false\n  private var meshDetector:FaceMeshDetector?=null\n  private var fallbackDetector:FaceDetector?=null\n  private val busy=AtomicBoolean(false)\n  private var detectorInFlight=false\n  private var owner:LifecycleOwner?=null\n  private var errorReported=false\n  private var renderFailed=false\n\n  internal fun wantsFrames()=active && foreground && attached && filter!="طبيعي" && !renderFailed\n  internal fun reserveFrame():Int? = if(wantsFrames() && busy.compareAndSet(false,true))generation else null\n  internal fun abandonFrame(){busy.set(false)}\n  internal fun acceptFrame(bitmap:Bitmap,rotation:Int,crop:IntArray,token:Int){\n    main.post {\n      if(token!=generation || !wantsFrames()){bitmap.recycle();busy.set(false);return@post}\n      val geometry=FrameGeometry.create(bitmap.width,bitmap.height,rotation,facing=="front",pixelWidth,pixelHeight,crop[0],crop[1],crop[2],crop[3])\n      val image=InputImage.fromBitmap(bitmap,rotation)\n      detectorInFlight=true\n      fun finish(state:WarpFaceState) {\n        detectorInFlight=false\n        if(token!=generation || !wantsFrames()) {\n          bitmap.recycle();busy.set(false);closeDetectorsIfIdle();return\n        }\n        emitFace(state,geometry)\n        if(MORPH_FILTERS.contains(filter)) {\n          val gl=ensureRenderer()\n          if(gl==null){bitmap.recycle();busy.set(false);return}\n          gl.draw(bitmap,geometry,state,filter){rendered->main.post{\n            busy.set(false)\n            if(rendered && token==generation && wantsFrames() && !ready){\n              ready=true;textureView.alpha=1f\n              onReady(mapOf("engine" to "vision-camera-frames","filter" to filter))\n            }\n          }}\n        } else {bitmap.recycle();busy.set(false)}\n      }\n      fun fallback(){\n        if(token!=generation || !wantsFrames()){finish(WarpFaceState());return}\n        try {\n          val detector=fallbackDetector ?: FaceDetection.getClient(FaceDetectorOptions.Builder()\n            .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)\n            .setContourMode(FaceDetectorOptions.CONTOUR_MODE_ALL).setMinFaceSize(.12f).build()).also{fallbackDetector=it}\n          detector.process(image).addOnSuccessListener(mainExecutor){faces->\n            val face=chooseFace(faces)\n            val state=if(face!=null && isPoseSafe(face))fallbackFaceState(face,bitmap.width,bitmap.height,rotation) else WarpFaceState()\n            finish(state)\n          }.addOnFailureListener(mainExecutor){finish(WarpFaceState())}\n        } catch(e:Exception){finish(WarpFaceState());report(e)}\n      }\n      if(!MORPH_FILTERS.contains(filter)){fallback();return@post}\n      try {\n        val detector=meshDetector ?: FaceMeshDetection.getClient(FaceMeshDetectorOptions.Builder().build()).also{meshDetector=it}\n        detector.process(image).addOnSuccessListener(mainExecutor){meshes->\n          if(token!=generation || !wantsFrames()){finish(WarpFaceState());return@addOnSuccessListener}\n          val mesh=chooseMesh(meshes)\n          if(mesh==null || !isMeshPoseSafe(mesh))fallback()\n          else finish(meshFaceState(mesh,bitmap.width,bitmap.height,rotation))\n        }.addOnFailureListener(mainExecutor){fallback()}\n      } catch(e:Exception){fallback();report(e)}\n    }\n  }\n  private fun emitFace(state:WarpFaceState,g:FrameGeometry){\n    val a=g.uprightToDisplay(state.faceCenterX-state.faceRadiusX,state.faceCenterY-state.faceRadiusY)\n    val b=g.uprightToDisplay(state.faceCenterX+state.faceRadiusX,state.faceCenterY+state.faceRadiusY)\n    val density=resources.displayMetrics.density\n    val bounds=mapOf("x" to minOf(a.first,b.first)*pixelWidth/density,"y" to minOf(a.second,b.second)*pixelHeight/density,\n      "width" to abs(b.first-a.first)*pixelWidth/density,"height" to abs(b.second-a.second)*pixelHeight/density)\n    onFaceState(mapOf("visible" to state.hasFace,"filter" to filter,"bounds" to bounds))\n  }\n  fun setSessionId(value:String){\n    if(sessionId==value)return\n    Registry.remove(sessionId,this);sessionId=value\n    if(attached)Registry.put(value,this)\n  }\n  fun setFacing(value:String){if(facing!=value){facing=value;invalidateFrames()}}\n  fun setFilter(value:String){if(filter!=value){filter=value;renderFailed=false;errorReported=false;invalidateFrames()}}\n  fun setActive(value:Boolean){if(active!=value){active=value;invalidateFrames();if(!value)closeDetectorsIfIdle()}}\n  private fun invalidateFrames(){\n    ++generation;ready=false;textureView.alpha=0f\n    // Leave the output connected. Filter changes do not create or stop a camera.\n    if(filter=="طبيعي" || !active || !foreground) {\n      closeDetectorsIfIdle()\n      stopRenderer()\n    }\n  }\n  private fun stopRenderer(){\n    val gl=renderer ?: return\n    if(rendererStopping)return\n    rendererStopping=true\n    gl.release{main.post{if(renderer===gl){renderer=null;rendererStopping=false}}}\n  }\n  private fun closeDetectorsIfIdle(){\n    if(detectorInFlight)return\n    meshDetector?.close();meshDetector=null\n    fallbackDetector?.close();fallbackDetector=null\n  }\n  private fun ensureRenderer():WarpRenderer?{\n    if(renderFailed || rendererStopping || !textureView.isAvailable)return null\n    return renderer ?: WarpRenderer(textureView.surfaceTexture ?: return null){error->main.post{\n      renderFailed=true;textureView.alpha=0f;ready=false;stopRenderer();report(error)\n    }}.also{renderer=it}\n  }\n  private fun report(error:Throwable){\n    if(!attached || errorReported)return\n    errorReported=true\n    android.util.Log.e("SawaFaceWarp","Frame processing failed",error)\n    onError(mapOf("message" to "تعذر معالجة الفلتر. أعد اختيار الفلتر للمحاولة.","engine" to "vision-camera-frames"))\n  }\n  override fun onLayout(changed:Boolean,left:Int,top:Int,right:Int,bottom:Int){\n    pixelWidth=max(1,right-left);pixelHeight=max(1,bottom-top)\n    textureView.layout(0,0,pixelWidth,pixelHeight)\n  }\n  override fun onSurfaceTextureAvailable(surface:SurfaceTexture,width:Int,height:Int){}\n  override fun onSurfaceTextureSizeChanged(surface:SurfaceTexture,width:Int,height:Int){invalidateFrames()}\n  override fun onSurfaceTextureUpdated(surface:SurfaceTexture){}\n  override fun onSurfaceTextureDestroyed(surface:SurfaceTexture):Boolean{\n    ++generation;ready=false\n    val gl=renderer;renderer=null;rendererStopping=false\n    if(gl!=null)gl.release{surface.release()} else surface.release()\n    return false // TextureView must NOT destroy a surface still used by the GL thread.\n  }\n  override fun onAttachedToWindow(){\n    super.onAttachedToWindow();attached=true;Registry.put(sessionId,this)\n    owner=appContext.currentActivity as? LifecycleOwner\n    owner?.lifecycle?.addObserver(this)\n  }\n  override fun onDetachedFromWindow(){\n    attached=false;Registry.remove(sessionId,this);++generation\n    owner?.lifecycle?.removeObserver(this);owner=null\n    closeDetectorsIfIdle()\n    // Texture destruction callback owns EGL teardown and SurfaceTexture release.\n    super.onDetachedFromWindow()\n  }\n  override fun onStop(owner:LifecycleOwner){foreground=false;invalidateFrames()}\n  override fun onStart(owner:LifecycleOwner){foreground=true;invalidateFrames()}\n\n  private fun chooseMesh(meshes: List<FaceMesh>): FaceMesh? = meshes.maxByOrNull {\n    it.boundingBox.width() * it.boundingBox.height()\n  }\n\n  private fun chooseFace(faces: List<Face>): Face? = faces.maxByOrNull { it.boundingBox.width() * it.boundingBox.height() }\n\n  private fun isPoseSafe(face: Face): Boolean {\n    // Extreme head poses make 2D warps look unstable. Disable deformation for\n    // that frame and keep the untouched camera image instead.\n    return abs(face.headEulerAngl'... 8866 more characters,
    expected: /ImageAnalysis\.STRATEGY_KEEP_ONLY_LATEST/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/v3-1-face-warp.test.mjs:42:1
✖ native face analysis prefers 468-point Face Mesh and falls back to contours for unsafe or missed frames (0.697873ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /useFallback\(\)/. Input:
  
  'package com.sawaup.facewarp\n' +
    '\n' +
    'import android.content.Context\n' +
    'import android.graphics.Bitmap\n' +
    'import android.graphics.SurfaceTexture\n' +
    'import android.view.TextureView\n' +
    'import android.view.ViewGroup\n' +
    'import android.os.Handler\n' +
    'import android.os.Looper\n' +
    'import androidx.core.content.ContextCompat\n' +
    'import androidx.lifecycle.DefaultLifecycleObserver\n' +
    'import androidx.lifecycle.LifecycleOwner\n' +
    'import java.lang.ref.WeakReference\n' +
    'import java.util.concurrent.ConcurrentHashMap\n' +
    'import com.google.mlkit.vision.common.InputImage\n' +
    'import com.google.mlkit.vision.face.Face\n' +
    'import com.google.mlkit.vision.face.FaceDetection\n' +
    'import com.google.mlkit.vision.face.FaceDetector\n' +
    'import com.google.mlkit.vision.face.FaceDetectorOptions\n' +
    'import com.google.mlkit.vision.face.FaceContour\n' +
    'import com.google.mlkit.vision.facemesh.FaceMesh\n' +
    'import com.google.mlkit.vision.facemesh.FaceMeshDetection\n' +
    'import com.google.mlkit.vision.facemesh.FaceMeshDetector\n' +
    'import com.google.mlkit.vision.facemesh.FaceMeshDetectorOptions\n' +
    'import expo.modules.kotlin.AppContext\n' +
    'import expo.modules.kotlin.viewevent.EventDispatcher\n' +
    'import expo.modules.kotlin.views.ExpoView\n' +
    'import java.util.concurrent.atomic.AtomicBoolean\n' +
    'import kotlin.math.PI\n' +
    'import kotlin.math.abs\n' +
    'import kotlin.math.atan2\n' +
    'import kotlin.math.max\n' +
    '\n' +
    '\n' +
    '/** Processing/rendering only. VisionCamera is the sole owner of the Camera device. */\n' +
    'class SawaFaceWarpView(context: Context, appContext: AppContext) :\n' +
    '  ExpoView(context,appContext), TextureView.SurfaceTextureListener, DefaultLifecycleObserver {\n' +
    '  val onReady by EventDispatcher()\n' +
    '  val onError by EventDispatcher()\n' +
    '  val onFaceState by EventDispatcher()\n' +
    '  private val main=Handler(Looper.getMainLooper())\n' +
    '  private val mainExecutor=ContextCompat.getMainExecutor(context)\n' +
    '  private val textureView=TextureView(context).also {\n' +
    '    it.isOpaque=false; it.alpha=0f; it.surfaceTextureListener=this\n' +
    '    addView(it,ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT))\n' +
    '  }\n' +
    '  @Volatile private var active=false\n' +
    '  @Volatile private var foreground=true\n' +
    '  @Volatile private var attached=false\n' +
    '  @Volatile private var filter="طبيعي"\n' +
    '  @Volatile private var facing="front"\n' +
    '  @Volatile private var generation=0\n' +
    '  @Volatile private var pixelWidth=1\n' +
    '  @Volatile private var pixelHeight=1\n' +
    '  private var sessionId=""\n' +
    '  private var ready=false\n' +
    '  private var renderer:WarpRenderer?=null\n' +
    '  private var rendererStopping=false\n' +
    '  private var meshDetector:FaceMeshDetector?=null\n' +
    '  private var fallbackDetector:FaceDetector?=null\n' +
    '  private val busy=AtomicBoolean(false)\n' +
    '  private var detectorInFlight=false\n' +
    '  private var owner:LifecycleOwner?=null\n' +
    '  private var errorReported=false\n' +
    '  private var renderFailed=false\n' +
    '\n' +
    '  internal fun wantsFrames()=active && foreground && attached && filter!="طبيعي" && !renderFailed\n' +
    '  internal fun reserveFrame():Int? = if(wantsFrames() && busy.compareAndSet(false,true))generation else null\n' +
    '  internal fun abandonFrame(){busy.set(false)}\n' +
    '  internal fun acceptFrame(bitmap:Bitmap,rotation:Int,crop:IntArray,token:Int){\n' +
    '    main.post {\n' +
    '      if(token!=generation || !wantsFrames()){bitmap.recycle();busy.set(false);return@post}\n' +
    '      val geometry=FrameGeometry.create(bitmap.width,bitmap.height,rotation,facing=="front",pixelWidth,pixelHeight,crop[0],crop[1],crop[2],crop[3])\n' +
    '      val image=InputImage.fromBitmap(bitmap,rotation)\n' +
    '      detectorInFlight=true\n' +
    '      fun finish(state:WarpFaceState) {\n' +
    '        detectorInFlight=false\n' +
    '        if(token!=generation || !wantsFrames()) {\n' +
    '          bitmap.recycle();busy.set(false);closeDetectorsIfIdle();return\n' +
    '        }\n' +
    '        emitFace(state,geometry)\n' +
    '        if(MORPH_FILTERS.contains(filter)) {\n' +
    '          val gl=ensureRenderer()\n' +
    '          if(gl==null){bitmap.recycle();busy.set(false);return}\n' +
    '          gl.draw(bitmap,geometry,state,filter){rendered->main.post{\n' +
    '            busy.set(false)\n' +
    '            if(rendered && token==generation && wantsFrames() && !ready){\n' +
    '              ready=true;textureView.alpha=1f\n' +
    '              onReady(mapOf("engine" to "vision-camera-frames","filter" to filter))\n' +
    '            }\n' +
    '          }}\n' +
    '        } else {bitmap.recycle();busy.set(false)}\n' +
    '      }\n' +
    '      fun fallback(){\n' +
    '        if(token!=generation || !wantsFrames()){finish(WarpFaceState());return}\n' +
    '        try {\n' +
    '          val detector=fallbackDetector ?: FaceDetection.getClient(FaceDetectorOptions.Builder()\n' +
    '            .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)\n' +
    '            .setContourMode(FaceDetectorOptions.CONTOUR_MODE_ALL).setMinFaceSize(.12f).build()).also{fallbackDetector=it}\n' +
    '          detector.process(image).addOnSuccessListener(mainExecutor){faces->\n' +
    '            val face=chooseFace(faces)\n' +
    '            val state=if(face!=null && isPoseSafe(face))fallbackFaceState(face,bitmap.width,bitmap.height,rotation) else WarpFaceState()\n' +
    '            finish(state)\n' +
    '          }.addOnFailureListener(mainExecutor){finish(WarpFaceState())}\n' +
    '        } catch(e:Exception){finish(WarpFaceState());report(e)}\n' +
    '      }\n' +
    '      if(!MORPH_FILTERS.contains(filter)){fallback();return@post}\n' +
    '      try {\n' +
    '        val detector=meshDetector ?: FaceMeshDetection.getClient(FaceMeshDetectorOptions.Builder().build()).also{meshDetector=it}\n' +
    '        detector.process(image).addOnSuccessListener(mainExecutor){meshes->\n' +
    '          if(token!=generation || !wantsFrames()){finish(WarpFaceState());return@addOnSuccessListener}\n' +
    '          val mesh=chooseMesh(meshes)\n' +
    '          if(mesh==null || !isMeshPoseSafe(mesh))fallback()\n' +
    '          else finish(meshFaceState(mesh,bitmap.width,bitmap.height,rotation))\n' +
    '        }.addOnFailureListener(mainExecutor){fallback()}\n' +
    '      } catch(e:Exception){fallback();report(e)}\n' +
    '    }\n' +
    '  }\n' +
    '  private fun emitFace(state:WarpFaceState,g:FrameGeometry){\n' +
    '    val a=g.uprightToDisplay(state.faceCenterX-state.faceRadiusX,state.faceCenterY-state.faceRadiusY)\n' +
    '    val b=g.uprightToDisplay(state.faceCenterX+state.faceRadiusX,state.faceCenterY+state.faceRadiusY)\n' +
    '    val density=resources.displayMetrics.density\n' +
    '    val bounds=mapOf("x" to minOf(a.first,b.first)*pixelWidth/density,"y" to minOf(a.second,b.second)*pixelHeight/density,\n' +
    '      "width" to abs(b.first-a.first)*pixelWidth/density,"height" to abs(b.second-a.second)*pixelHeight/density)\n' +
    '    onFaceState(mapOf("visible" to state.hasFace,"filter" to filter,"bounds" to bounds))\n' +
    '  }\n' +
    '  fun setSessionId(value:String){\n' +
    '    if(sessionId==value)return\n' +
    '    Registry.remove(sessionId,this);sessionId=value\n' +
    '    if(attached)Registry.put(value,this)\n' +
    '  }\n' +
    '  fun setFacing(value:String){if(facing!=value){facing=value;invalidateFrames()}}\n' +
    '  fun setFilter(value:String){if(filter!=value){filter=value;renderFailed=false;errorReported=false;invalidateFrames()}}\n' +
    '  fun setActive(value:Boolean){if(active!=value){active=value;invalidateFrames();if(!value)closeDetectorsIfIdle()}}\n' +
    '  private fun invalidateFrames(){\n' +
    '    ++generation;ready=false;textureView.alpha=0f\n' +
    '    // Leave the output connected. Filter changes do not create or stop a camera.\n' +
    '    if(filter=="طبيعي" || !active || !foreground) {\n' +
    '      closeDetectorsIfIdle()\n' +
    '      stopRenderer()\n' +
    '    }\n' +
    '  }\n' +
    '  private fun stopRenderer(){\n' +
    '    val gl=renderer ?: return\n' +
    '    if(rendererStopping)return\n' +
    '    rendererStopping=true\n' +
    '    gl.release{main.post{if(renderer===gl){renderer=null;rendererStopping=false}}}\n' +
    '  }\n' +
    '  private fun closeDetectorsIfIdle(){\n' +
    '    if(detectorInFlight)return\n' +
    '    meshDetector?.close();meshDetector=null\n' +
    '    fallbackDetector?.close();fallbackDetector=null\n' +
    '  }\n' +
    '  private fun ensureRenderer():WarpRenderer?{\n' +
    '    if(renderFailed || rendererStopping || !textureView.isAvailable)return null\n' +
    '    return renderer ?: WarpRenderer(textureView.surfaceTexture ?: return null){error->main.post{\n' +
    '      renderFailed=true;textureView.alpha=0f;ready=false;stopRenderer();report(error)\n' +
    '    }}.also{renderer=it}\n' +
    '  }\n' +
    '  private fun report(error:Throwable){\n' +
    '    if(!attached || errorReported)return\n' +
    '    errorReported=true\n' +
    '    android.util.Log.e("SawaFaceWarp","Frame processing failed",error)\n' +
    '    onError(mapOf("message" to "تعذر معالجة الفلتر. أعد اختيار الفلتر للمحاولة.","engine" to "vision-camera-frames"))\n' +
    '  }\n' +
    '  override fun onLayout(changed:Boolean,left:Int,top:Int,right:Int,bottom:Int){\n' +
    '    pixelWidth=max(1,right-left);pixelHeight=max(1,bottom-top)\n' +
    '    textureView.layout(0,0,pixelWidth,pixelHeight)\n' +
    '  }\n' +
    '  override fun onSurfaceTextureAvailable(surface:SurfaceTexture,width:Int,height:Int){}\n' +
    '  override fun onSurfaceTextureSizeChanged(surface:SurfaceTexture,width:Int,height:Int){invalidateFrames()}\n' +
    '  override fun onSurfaceTextureUpdated(surface:SurfaceTexture){}\n' +
    '  override fun onSurfaceTextureDestroyed(surface:SurfaceTexture):Boolean{\n' +
    '    ++generation;ready=false\n' +
    '    val gl=renderer;renderer=null;rendererStopping=false\n' +
    '    if(gl!=null)gl.release{surface.release()} else surface.release()\n' +
    '    return false // TextureView must NOT destroy a surface still used by the GL thread.\n' +
    '  }\n' +
    '  override fun onAttachedToWindow(){\n' +
    '    super.onAttachedToWindow();attached=true;Registry.put(sessionId,this)\n' +
    '    owner=appContext.currentActivity as? LifecycleOwner\n' +
    '    owner?.lifecycle?.addObserver(this)\n' +
    '  }\n' +
    '  override fun onDetachedFromWindow(){\n' +
    '    attached=false;Registry.remove(sessionId,this);++generation\n' +
    '    owner?.lifecycle?.removeObserver(this);owner=null\n' +
    '    closeDetectorsIfIdle()\n' +
    '    // Texture destruction callback owns EGL teardown and SurfaceTexture release.\n' +
    '    super.onDetachedFromWindow()\n' +
    '  }\n' +
    '  override fun onStop(owner:LifecycleOwner){foreground=false;invalidateFrames()}\n' +
    '  override fun onStart(owner:LifecycleOwner){foreground=true;invalidateFrames()}\n' +
    '\n' +
    '  private fun chooseMesh(meshes: List<FaceMesh>): FaceMesh? = meshes.maxByOrNull {\n' +
    '    it.boundingBox.width() * it.boundingBox.height()\n' +
    '  }\n' +
    '\n' +
    '  private fun chooseFace(faces: List<Face>): Face? = faces.maxByOrNull { it.boundingBox.width() * it.boundingBox.height() }\n' +
    '\n' +
    '  private fun isPoseSafe(face: Face): Boolean {\n' +
    '    // Extreme head poses make 2D warps look unstable. Disable deformation for\n' +
    '    // that frame and keep the untouched camera image instead.\n' +
    '    return abs(face.headEulerAngl'... 8866 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/v3-1-face-warp.test.mjs:49:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async Test.processPendingSubtests (node:internal/test_runner/test:960:7) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: 'package com.sawaup.facewarp\n\nimport android.content.Context\nimport android.graphics.Bitmap\nimport android.graphics.SurfaceTexture\nimport android.view.TextureView\nimport android.view.ViewGroup\nimport android.os.Handler\nimport android.os.Looper\nimport androidx.core.content.ContextCompat\nimport androidx.lifecycle.DefaultLifecycleObserver\nimport androidx.lifecycle.LifecycleOwner\nimport java.lang.ref.WeakReference\nimport java.util.concurrent.ConcurrentHashMap\nimport com.google.mlkit.vision.common.InputImage\nimport com.google.mlkit.vision.face.Face\nimport com.google.mlkit.vision.face.FaceDetection\nimport com.google.mlkit.vision.face.FaceDetector\nimport com.google.mlkit.vision.face.FaceDetectorOptions\nimport com.google.mlkit.vision.face.FaceContour\nimport com.google.mlkit.vision.facemesh.FaceMesh\nimport com.google.mlkit.vision.facemesh.FaceMeshDetection\nimport com.google.mlkit.vision.facemesh.FaceMeshDetector\nimport com.google.mlkit.vision.facemesh.FaceMeshDetectorOptions\nimport expo.modules.kotlin.AppContext\nimport expo.modules.kotlin.viewevent.EventDispatcher\nimport expo.modules.kotlin.views.ExpoView\nimport java.util.concurrent.atomic.AtomicBoolean\nimport kotlin.math.PI\nimport kotlin.math.abs\nimport kotlin.math.atan2\nimport kotlin.math.max\n\n\n/** Processing/rendering only. VisionCamera is the sole owner of the Camera device. */\nclass SawaFaceWarpView(context: Context, appContext: AppContext) :\n  ExpoView(context,appContext), TextureView.SurfaceTextureListener, DefaultLifecycleObserver {\n  val onReady by EventDispatcher()\n  val onError by EventDispatcher()\n  val onFaceState by EventDispatcher()\n  private val main=Handler(Looper.getMainLooper())\n  private val mainExecutor=ContextCompat.getMainExecutor(context)\n  private val textureView=TextureView(context).also {\n    it.isOpaque=false; it.alpha=0f; it.surfaceTextureListener=this\n    addView(it,ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT))\n  }\n  @Volatile private var active=false\n  @Volatile private var foreground=true\n  @Volatile private var attached=false\n  @Volatile private var filter="طبيعي"\n  @Volatile private var facing="front"\n  @Volatile private var generation=0\n  @Volatile private var pixelWidth=1\n  @Volatile private var pixelHeight=1\n  private var sessionId=""\n  private var ready=false\n  private var renderer:WarpRenderer?=null\n  private var rendererStopping=false\n  private var meshDetector:FaceMeshDetector?=null\n  private var fallbackDetector:FaceDetector?=null\n  private val busy=AtomicBoolean(false)\n  private var detectorInFlight=false\n  private var owner:LifecycleOwner?=null\n  private var errorReported=false\n  private var renderFailed=false\n\n  internal fun wantsFrames()=active && foreground && attached && filter!="طبيعي" && !renderFailed\n  internal fun reserveFrame():Int? = if(wantsFrames() && busy.compareAndSet(false,true))generation else null\n  internal fun abandonFrame(){busy.set(false)}\n  internal fun acceptFrame(bitmap:Bitmap,rotation:Int,crop:IntArray,token:Int){\n    main.post {\n      if(token!=generation || !wantsFrames()){bitmap.recycle();busy.set(false);return@post}\n      val geometry=FrameGeometry.create(bitmap.width,bitmap.height,rotation,facing=="front",pixelWidth,pixelHeight,crop[0],crop[1],crop[2],crop[3])\n      val image=InputImage.fromBitmap(bitmap,rotation)\n      detectorInFlight=true\n      fun finish(state:WarpFaceState) {\n        detectorInFlight=false\n        if(token!=generation || !wantsFrames()) {\n          bitmap.recycle();busy.set(false);closeDetectorsIfIdle();return\n        }\n        emitFace(state,geometry)\n        if(MORPH_FILTERS.contains(filter)) {\n          val gl=ensureRenderer()\n          if(gl==null){bitmap.recycle();busy.set(false);return}\n          gl.draw(bitmap,geometry,state,filter){rendered->main.post{\n            busy.set(false)\n            if(rendered && token==generation && wantsFrames() && !ready){\n              ready=true;textureView.alpha=1f\n              onReady(mapOf("engine" to "vision-camera-frames","filter" to filter))\n            }\n          }}\n        } else {bitmap.recycle();busy.set(false)}\n      }\n      fun fallback(){\n        if(token!=generation || !wantsFrames()){finish(WarpFaceState());return}\n        try {\n          val detector=fallbackDetector ?: FaceDetection.getClient(FaceDetectorOptions.Builder()\n            .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)\n            .setContourMode(FaceDetectorOptions.CONTOUR_MODE_ALL).setMinFaceSize(.12f).build()).also{fallbackDetector=it}\n          detector.process(image).addOnSuccessListener(mainExecutor){faces->\n            val face=chooseFace(faces)\n            val state=if(face!=null && isPoseSafe(face))fallbackFaceState(face,bitmap.width,bitmap.height,rotation) else WarpFaceState()\n            finish(state)\n          }.addOnFailureListener(mainExecutor){finish(WarpFaceState())}\n        } catch(e:Exception){finish(WarpFaceState());report(e)}\n      }\n      if(!MORPH_FILTERS.contains(filter)){fallback();return@post}\n      try {\n        val detector=meshDetector ?: FaceMeshDetection.getClient(FaceMeshDetectorOptions.Builder().build()).also{meshDetector=it}\n        detector.process(image).addOnSuccessListener(mainExecutor){meshes->\n          if(token!=generation || !wantsFrames()){finish(WarpFaceState());return@addOnSuccessListener}\n          val mesh=chooseMesh(meshes)\n          if(mesh==null || !isMeshPoseSafe(mesh))fallback()\n          else finish(meshFaceState(mesh,bitmap.width,bitmap.height,rotation))\n        }.addOnFailureListener(mainExecutor){fallback()}\n      } catch(e:Exception){fallback();report(e)}\n    }\n  }\n  private fun emitFace(state:WarpFaceState,g:FrameGeometry){\n    val a=g.uprightToDisplay(state.faceCenterX-state.faceRadiusX,state.faceCenterY-state.faceRadiusY)\n    val b=g.uprightToDisplay(state.faceCenterX+state.faceRadiusX,state.faceCenterY+state.faceRadiusY)\n    val density=resources.displayMetrics.density\n    val bounds=mapOf("x" to minOf(a.first,b.first)*pixelWidth/density,"y" to minOf(a.second,b.second)*pixelHeight/density,\n      "width" to abs(b.first-a.first)*pixelWidth/density,"height" to abs(b.second-a.second)*pixelHeight/density)\n    onFaceState(mapOf("visible" to state.hasFace,"filter" to filter,"bounds" to bounds))\n  }\n  fun setSessionId(value:String){\n    if(sessionId==value)return\n    Registry.remove(sessionId,this);sessionId=value\n    if(attached)Registry.put(value,this)\n  }\n  fun setFacing(value:String){if(facing!=value){facing=value;invalidateFrames()}}\n  fun setFilter(value:String){if(filter!=value){filter=value;renderFailed=false;errorReported=false;invalidateFrames()}}\n  fun setActive(value:Boolean){if(active!=value){active=value;invalidateFrames();if(!value)closeDetectorsIfIdle()}}\n  private fun invalidateFrames(){\n    ++generation;ready=false;textureView.alpha=0f\n    // Leave the output connected. Filter changes do not create or stop a camera.\n    if(filter=="طبيعي" || !active || !foreground) {\n      closeDetectorsIfIdle()\n      stopRenderer()\n    }\n  }\n  private fun stopRenderer(){\n    val gl=renderer ?: return\n    if(rendererStopping)return\n    rendererStopping=true\n    gl.release{main.post{if(renderer===gl){renderer=null;rendererStopping=false}}}\n  }\n  private fun closeDetectorsIfIdle(){\n    if(detectorInFlight)return\n    meshDetector?.close();meshDetector=null\n    fallbackDetector?.close();fallbackDetector=null\n  }\n  private fun ensureRenderer():WarpRenderer?{\n    if(renderFailed || rendererStopping || !textureView.isAvailable)return null\n    return renderer ?: WarpRenderer(textureView.surfaceTexture ?: return null){error->main.post{\n      renderFailed=true;textureView.alpha=0f;ready=false;stopRenderer();report(error)\n    }}.also{renderer=it}\n  }\n  private fun report(error:Throwable){\n    if(!attached || errorReported)return\n    errorReported=true\n    android.util.Log.e("SawaFaceWarp","Frame processing failed",error)\n    onError(mapOf("message" to "تعذر معالجة الفلتر. أعد اختيار الفلتر للمحاولة.","engine" to "vision-camera-frames"))\n  }\n  override fun onLayout(changed:Boolean,left:Int,top:Int,right:Int,bottom:Int){\n    pixelWidth=max(1,right-left);pixelHeight=max(1,bottom-top)\n    textureView.layout(0,0,pixelWidth,pixelHeight)\n  }\n  override fun onSurfaceTextureAvailable(surface:SurfaceTexture,width:Int,height:Int){}\n  override fun onSurfaceTextureSizeChanged(surface:SurfaceTexture,width:Int,height:Int){invalidateFrames()}\n  override fun onSurfaceTextureUpdated(surface:SurfaceTexture){}\n  override fun onSurfaceTextureDestroyed(surface:SurfaceTexture):Boolean{\n    ++generation;ready=false\n    val gl=renderer;renderer=null;rendererStopping=false\n    if(gl!=null)gl.release{surface.release()} else surface.release()\n    return false // TextureView must NOT destroy a surface still used by the GL thread.\n  }\n  override fun onAttachedToWindow(){\n    super.onAttachedToWindow();attached=true;Registry.put(sessionId,this)\n    owner=appContext.currentActivity as? LifecycleOwner\n    owner?.lifecycle?.addObserver(this)\n  }\n  override fun onDetachedFromWindow(){\n    attached=false;Registry.remove(sessionId,this);++generation\n    owner?.lifecycle?.removeObserver(this);owner=null\n    closeDetectorsIfIdle()\n    // Texture destruction callback owns EGL teardown and SurfaceTexture release.\n    super.onDetachedFromWindow()\n  }\n  override fun onStop(owner:LifecycleOwner){foreground=false;invalidateFrames()}\n  override fun onStart(owner:LifecycleOwner){foreground=true;invalidateFrames()}\n\n  private fun chooseMesh(meshes: List<FaceMesh>): FaceMesh? = meshes.maxByOrNull {\n    it.boundingBox.width() * it.boundingBox.height()\n  }\n\n  private fun chooseFace(faces: List<Face>): Face? = faces.maxByOrNull { it.boundingBox.width() * it.boundingBox.height() }\n\n  private fun isPoseSafe(face: Face): Boolean {\n    // Extreme head poses make 2D warps look unstable. Disable deformation for\n    // that frame and keep the untouched camera image instead.\n    return abs(face.headEulerAngl'... 8866 more characters,
    expected: /useFallback\(\)/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/v3-1-face-warp.test.mjs:59:1
✖ CameraX surface lifetime is owned until SurfaceRequest detaches (0.555084ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /localRenderer\.retainCameraSurface\(\)/. Input:
  
  'package com.sawaup.facewarp\n' +
    '\n' +
    'import android.content.Context\n' +
    'import android.graphics.Bitmap\n' +
    'import android.graphics.SurfaceTexture\n' +
    'import android.view.TextureView\n' +
    'import android.view.ViewGroup\n' +
    'import android.os.Handler\n' +
    'import android.os.Looper\n' +
    'import androidx.core.content.ContextCompat\n' +
    'import androidx.lifecycle.DefaultLifecycleObserver\n' +
    'import androidx.lifecycle.LifecycleOwner\n' +
    'import java.lang.ref.WeakReference\n' +
    'import java.util.concurrent.ConcurrentHashMap\n' +
    'import com.google.mlkit.vision.common.InputImage\n' +
    'import com.google.mlkit.vision.face.Face\n' +
    'import com.google.mlkit.vision.face.FaceDetection\n' +
    'import com.google.mlkit.vision.face.FaceDetector\n' +
    'import com.google.mlkit.vision.face.FaceDetectorOptions\n' +
    'import com.google.mlkit.vision.face.FaceContour\n' +
    'import com.google.mlkit.vision.facemesh.FaceMesh\n' +
    'import com.google.mlkit.vision.facemesh.FaceMeshDetection\n' +
    'import com.google.mlkit.vision.facemesh.FaceMeshDetector\n' +
    'import com.google.mlkit.vision.facemesh.FaceMeshDetectorOptions\n' +
    'import expo.modules.kotlin.AppContext\n' +
    'import expo.modules.kotlin.viewevent.EventDispatcher\n' +
    'import expo.modules.kotlin.views.ExpoView\n' +
    'import java.util.concurrent.atomic.AtomicBoolean\n' +
    'import kotlin.math.PI\n' +
    'import kotlin.math.abs\n' +
    'import kotlin.math.atan2\n' +
    'import kotlin.math.max\n' +
    '\n' +
    '\n' +
    '/** Processing/rendering only. VisionCamera is the sole owner of the Camera device. */\n' +
    'class SawaFaceWarpView(context: Context, appContext: AppContext) :\n' +
    '  ExpoView(context,appContext), TextureView.SurfaceTextureListener, DefaultLifecycleObserver {\n' +
    '  val onReady by EventDispatcher()\n' +
    '  val onError by EventDispatcher()\n' +
    '  val onFaceState by EventDispatcher()\n' +
    '  private val main=Handler(Looper.getMainLooper())\n' +
    '  private val mainExecutor=ContextCompat.getMainExecutor(context)\n' +
    '  private val textureView=TextureView(context).also {\n' +
    '    it.isOpaque=false; it.alpha=0f; it.surfaceTextureListener=this\n' +
    '    addView(it,ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT))\n' +
    '  }\n' +
    '  @Volatile private var active=false\n' +
    '  @Volatile private var foreground=true\n' +
    '  @Volatile private var attached=false\n' +
    '  @Volatile private var filter="طبيعي"\n' +
    '  @Volatile private var facing="front"\n' +
    '  @Volatile private var generation=0\n' +
    '  @Volatile private var pixelWidth=1\n' +
    '  @Volatile private var pixelHeight=1\n' +
    '  private var sessionId=""\n' +
    '  private var ready=false\n' +
    '  private var renderer:WarpRenderer?=null\n' +
    '  private var rendererStopping=false\n' +
    '  private var meshDetector:FaceMeshDetector?=null\n' +
    '  private var fallbackDetector:FaceDetector?=null\n' +
    '  private val busy=AtomicBoolean(false)\n' +
    '  private var detectorInFlight=false\n' +
    '  private var owner:LifecycleOwner?=null\n' +
    '  private var errorReported=false\n' +
    '  private var renderFailed=false\n' +
    '\n' +
    '  internal fun wantsFrames()=active && foreground && attached && filter!="طبيعي" && !renderFailed\n' +
    '  internal fun reserveFrame():Int? = if(wantsFrames() && busy.compareAndSet(false,true))generation else null\n' +
    '  internal fun abandonFrame(){busy.set(false)}\n' +
    '  internal fun acceptFrame(bitmap:Bitmap,rotation:Int,crop:IntArray,token:Int){\n' +
    '    main.post {\n' +
    '      if(token!=generation || !wantsFrames()){bitmap.recycle();busy.set(false);return@post}\n' +
    '      val geometry=FrameGeometry.create(bitmap.width,bitmap.height,rotation,facing=="front",pixelWidth,pixelHeight,crop[0],crop[1],crop[2],crop[3])\n' +
    '      val image=InputImage.fromBitmap(bitmap,rotation)\n' +
    '      detectorInFlight=true\n' +
    '      fun finish(state:WarpFaceState) {\n' +
    '        detectorInFlight=false\n' +
    '        if(token!=generation || !wantsFrames()) {\n' +
    '          bitmap.recycle();busy.set(false);closeDetectorsIfIdle();return\n' +
    '        }\n' +
    '        emitFace(state,geometry)\n' +
    '        if(MORPH_FILTERS.contains(filter)) {\n' +
    '          val gl=ensureRenderer()\n' +
    '          if(gl==null){bitmap.recycle();busy.set(false);return}\n' +
    '          gl.draw(bitmap,geometry,state,filter){rendered->main.post{\n' +
    '            busy.set(false)\n' +
    '            if(rendered && token==generation && wantsFrames() && !ready){\n' +
    '              ready=true;textureView.alpha=1f\n' +
    '              onReady(mapOf("engine" to "vision-camera-frames","filter" to filter))\n' +
    '            }\n' +
    '          }}\n' +
    '        } else {bitmap.recycle();busy.set(false)}\n' +
    '      }\n' +
    '      fun fallback(){\n' +
    '        if(token!=generation || !wantsFrames()){finish(WarpFaceState());return}\n' +
    '        try {\n' +
    '          val detector=fallbackDetector ?: FaceDetection.getClient(FaceDetectorOptions.Builder()\n' +
    '            .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)\n' +
    '            .setContourMode(FaceDetectorOptions.CONTOUR_MODE_ALL).setMinFaceSize(.12f).build()).also{fallbackDetector=it}\n' +
    '          detector.process(image).addOnSuccessListener(mainExecutor){faces->\n' +
    '            val face=chooseFace(faces)\n' +
    '            val state=if(face!=null && isPoseSafe(face))fallbackFaceState(face,bitmap.width,bitmap.height,rotation) else WarpFaceState()\n' +
    '            finish(state)\n' +
    '          }.addOnFailureListener(mainExecutor){finish(WarpFaceState())}\n' +
    '        } catch(e:Exception){finish(WarpFaceState());report(e)}\n' +
    '      }\n' +
    '      if(!MORPH_FILTERS.contains(filter)){fallback();return@post}\n' +
    '      try {\n' +
    '        val detector=meshDetector ?: FaceMeshDetection.getClient(FaceMeshDetectorOptions.Builder().build()).also{meshDetector=it}\n' +
    '        detector.process(image).addOnSuccessListener(mainExecutor){meshes->\n' +
    '          if(token!=generation || !wantsFrames()){finish(WarpFaceState());return@addOnSuccessListener}\n' +
    '          val mesh=chooseMesh(meshes)\n' +
    '          if(mesh==null || !isMeshPoseSafe(mesh))fallback()\n' +
    '          else finish(meshFaceState(mesh,bitmap.width,bitmap.height,rotation))\n' +
    '        }.addOnFailureListener(mainExecutor){fallback()}\n' +
    '      } catch(e:Exception){fallback();report(e)}\n' +
    '    }\n' +
    '  }\n' +
    '  private fun emitFace(state:WarpFaceState,g:FrameGeometry){\n' +
    '    val a=g.uprightToDisplay(state.faceCenterX-state.faceRadiusX,state.faceCenterY-state.faceRadiusY)\n' +
    '    val b=g.uprightToDisplay(state.faceCenterX+state.faceRadiusX,state.faceCenterY+state.faceRadiusY)\n' +
    '    val density=resources.displayMetrics.density\n' +
    '    val bounds=mapOf("x" to minOf(a.first,b.first)*pixelWidth/density,"y" to minOf(a.second,b.second)*pixelHeight/density,\n' +
    '      "width" to abs(b.first-a.first)*pixelWidth/density,"height" to abs(b.second-a.second)*pixelHeight/density)\n' +
    '    onFaceState(mapOf("visible" to state.hasFace,"filter" to filter,"bounds" to bounds))\n' +
    '  }\n' +
    '  fun setSessionId(value:String){\n' +
    '    if(sessionId==value)return\n' +
    '    Registry.remove(sessionId,this);sessionId=value\n' +
    '    if(attached)Registry.put(value,this)\n' +
    '  }\n' +
    '  fun setFacing(value:String){if(facing!=value){facing=value;invalidateFrames()}}\n' +
    '  fun setFilter(value:String){if(filter!=value){filter=value;renderFailed=false;errorReported=false;invalidateFrames()}}\n' +
    '  fun setActive(value:Boolean){if(active!=value){active=value;invalidateFrames();if(!value)closeDetectorsIfIdle()}}\n' +
    '  private fun invalidateFrames(){\n' +
    '    ++generation;ready=false;textureView.alpha=0f\n' +
    '    // Leave the output connected. Filter changes do not create or stop a camera.\n' +
    '    if(filter=="طبيعي" || !active || !foreground) {\n' +
    '      closeDetectorsIfIdle()\n' +
    '      stopRenderer()\n' +
    '    }\n' +
    '  }\n' +
    '  private fun stopRenderer(){\n' +
    '    val gl=renderer ?: return\n' +
    '    if(rendererStopping)return\n' +
    '    rendererStopping=true\n' +
    '    gl.release{main.post{if(renderer===gl){renderer=null;rendererStopping=false}}}\n' +
    '  }\n' +
    '  private fun closeDetectorsIfIdle(){\n' +
    '    if(detectorInFlight)return\n' +
    '    meshDetector?.close();meshDetector=null\n' +
    '    fallbackDetector?.close();fallbackDetector=null\n' +
    '  }\n' +
    '  private fun ensureRenderer():WarpRenderer?{\n' +
    '    if(renderFailed || rendererStopping || !textureView.isAvailable)return null\n' +
    '    return renderer ?: WarpRenderer(textureView.surfaceTexture ?: return null){error->main.post{\n' +
    '      renderFailed=true;textureView.alpha=0f;ready=false;stopRenderer();report(error)\n' +
    '    }}.also{renderer=it}\n' +
    '  }\n' +
    '  private fun report(error:Throwable){\n' +
    '    if(!attached || errorReported)return\n' +
    '    errorReported=true\n' +
    '    android.util.Log.e("SawaFaceWarp","Frame processing failed",error)\n' +
    '    onError(mapOf("message" to "تعذر معالجة الفلتر. أعد اختيار الفلتر للمحاولة.","engine" to "vision-camera-frames"))\n' +
    '  }\n' +
    '  override fun onLayout(changed:Boolean,left:Int,top:Int,right:Int,bottom:Int){\n' +
    '    pixelWidth=max(1,right-left);pixelHeight=max(1,bottom-top)\n' +
    '    textureView.layout(0,0,pixelWidth,pixelHeight)\n' +
    '  }\n' +
    '  override fun onSurfaceTextureAvailable(surface:SurfaceTexture,width:Int,height:Int){}\n' +
    '  override fun onSurfaceTextureSizeChanged(surface:SurfaceTexture,width:Int,height:Int){invalidateFrames()}\n' +
    '  override fun onSurfaceTextureUpdated(surface:SurfaceTexture){}\n' +
    '  override fun onSurfaceTextureDestroyed(surface:SurfaceTexture):Boolean{\n' +
    '    ++generation;ready=false\n' +
    '    val gl=renderer;renderer=null;rendererStopping=false\n' +
    '    if(gl!=null)gl.release{surface.release()} else surface.release()\n' +
    '    return false // TextureView must NOT destroy a surface still used by the GL thread.\n' +
    '  }\n' +
    '  override fun onAttachedToWindow(){\n' +
    '    super.onAttachedToWindow();attached=true;Registry.put(sessionId,this)\n' +
    '    owner=appContext.currentActivity as? LifecycleOwner\n' +
    '    owner?.lifecycle?.addObserver(this)\n' +
    '  }\n' +
    '  override fun onDetachedFromWindow(){\n' +
    '    attached=false;Registry.remove(sessionId,this);++generation\n' +
    '    owner?.lifecycle?.removeObserver(this);owner=null\n' +
    '    closeDetectorsIfIdle()\n' +
    '    // Texture destruction callback owns EGL teardown and SurfaceTexture release.\n' +
    '    super.onDetachedFromWindow()\n' +
    '  }\n' +
    '  override fun onStop(owner:LifecycleOwner){foreground=false;invalidateFrames()}\n' +
    '  override fun onStart(owner:LifecycleOwner){foreground=true;invalidateFrames()}\n' +
    '\n' +
    '  private fun chooseMesh(meshes: List<FaceMesh>): FaceMesh? = meshes.maxByOrNull {\n' +
    '    it.boundingBox.width() * it.boundingBox.height()\n' +
    '  }\n' +
    '\n' +
    '  private fun chooseFace(faces: List<Face>): Face? = faces.maxByOrNull { it.boundingBox.width() * it.boundingBox.height() }\n' +
    '\n' +
    '  private fun isPoseSafe(face: Face): Boolean {\n' +
    '    // Extreme head poses make 2D warps look unstable. Disable deformation for\n' +
    '    // that frame and keep the untouched camera image instead.\n' +
    '    return abs(face.headEulerAngl'... 8866 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/v3-1-face-warp.test.mjs:60:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async Test.processPendingSubtests (node:internal/test_runner/test:960:7) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: 'package com.sawaup.facewarp\n\nimport android.content.Context\nimport android.graphics.Bitmap\nimport android.graphics.SurfaceTexture\nimport android.view.TextureView\nimport android.view.ViewGroup\nimport android.os.Handler\nimport android.os.Looper\nimport androidx.core.content.ContextCompat\nimport androidx.lifecycle.DefaultLifecycleObserver\nimport androidx.lifecycle.LifecycleOwner\nimport java.lang.ref.WeakReference\nimport java.util.concurrent.ConcurrentHashMap\nimport com.google.mlkit.vision.common.InputImage\nimport com.google.mlkit.vision.face.Face\nimport com.google.mlkit.vision.face.FaceDetection\nimport com.google.mlkit.vision.face.FaceDetector\nimport com.google.mlkit.vision.face.FaceDetectorOptions\nimport com.google.mlkit.vision.face.FaceContour\nimport com.google.mlkit.vision.facemesh.FaceMesh\nimport com.google.mlkit.vision.facemesh.FaceMeshDetection\nimport com.google.mlkit.vision.facemesh.FaceMeshDetector\nimport com.google.mlkit.vision.facemesh.FaceMeshDetectorOptions\nimport expo.modules.kotlin.AppContext\nimport expo.modules.kotlin.viewevent.EventDispatcher\nimport expo.modules.kotlin.views.ExpoView\nimport java.util.concurrent.atomic.AtomicBoolean\nimport kotlin.math.PI\nimport kotlin.math.abs\nimport kotlin.math.atan2\nimport kotlin.math.max\n\n\n/** Processing/rendering only. VisionCamera is the sole owner of the Camera device. */\nclass SawaFaceWarpView(context: Context, appContext: AppContext) :\n  ExpoView(context,appContext), TextureView.SurfaceTextureListener, DefaultLifecycleObserver {\n  val onReady by EventDispatcher()\n  val onError by EventDispatcher()\n  val onFaceState by EventDispatcher()\n  private val main=Handler(Looper.getMainLooper())\n  private val mainExecutor=ContextCompat.getMainExecutor(context)\n  private val textureView=TextureView(context).also {\n    it.isOpaque=false; it.alpha=0f; it.surfaceTextureListener=this\n    addView(it,ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT))\n  }\n  @Volatile private var active=false\n  @Volatile private var foreground=true\n  @Volatile private var attached=false\n  @Volatile private var filter="طبيعي"\n  @Volatile private var facing="front"\n  @Volatile private var generation=0\n  @Volatile private var pixelWidth=1\n  @Volatile private var pixelHeight=1\n  private var sessionId=""\n  private var ready=false\n  private var renderer:WarpRenderer?=null\n  private var rendererStopping=false\n  private var meshDetector:FaceMeshDetector?=null\n  private var fallbackDetector:FaceDetector?=null\n  private val busy=AtomicBoolean(false)\n  private var detectorInFlight=false\n  private var owner:LifecycleOwner?=null\n  private var errorReported=false\n  private var renderFailed=false\n\n  internal fun wantsFrames()=active && foreground && attached && filter!="طبيعي" && !renderFailed\n  internal fun reserveFrame():Int? = if(wantsFrames() && busy.compareAndSet(false,true))generation else null\n  internal fun abandonFrame(){busy.set(false)}\n  internal fun acceptFrame(bitmap:Bitmap,rotation:Int,crop:IntArray,token:Int){\n    main.post {\n      if(token!=generation || !wantsFrames()){bitmap.recycle();busy.set(false);return@post}\n      val geometry=FrameGeometry.create(bitmap.width,bitmap.height,rotation,facing=="front",pixelWidth,pixelHeight,crop[0],crop[1],crop[2],crop[3])\n      val image=InputImage.fromBitmap(bitmap,rotation)\n      detectorInFlight=true\n      fun finish(state:WarpFaceState) {\n        detectorInFlight=false\n        if(token!=generation || !wantsFrames()) {\n          bitmap.recycle();busy.set(false);closeDetectorsIfIdle();return\n        }\n        emitFace(state,geometry)\n        if(MORPH_FILTERS.contains(filter)) {\n          val gl=ensureRenderer()\n          if(gl==null){bitmap.recycle();busy.set(false);return}\n          gl.draw(bitmap,geometry,state,filter){rendered->main.post{\n            busy.set(false)\n            if(rendered && token==generation && wantsFrames() && !ready){\n              ready=true;textureView.alpha=1f\n              onReady(mapOf("engine" to "vision-camera-frames","filter" to filter))\n            }\n          }}\n        } else {bitmap.recycle();busy.set(false)}\n      }\n      fun fallback(){\n        if(token!=generation || !wantsFrames()){finish(WarpFaceState());return}\n        try {\n          val detector=fallbackDetector ?: FaceDetection.getClient(FaceDetectorOptions.Builder()\n            .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)\n            .setContourMode(FaceDetectorOptions.CONTOUR_MODE_ALL).setMinFaceSize(.12f).build()).also{fallbackDetector=it}\n          detector.process(image).addOnSuccessListener(mainExecutor){faces->\n            val face=chooseFace(faces)\n            val state=if(face!=null && isPoseSafe(face))fallbackFaceState(face,bitmap.width,bitmap.height,rotation) else WarpFaceState()\n            finish(state)\n          }.addOnFailureListener(mainExecutor){finish(WarpFaceState())}\n        } catch(e:Exception){finish(WarpFaceState());report(e)}\n      }\n      if(!MORPH_FILTERS.contains(filter)){fallback();return@post}\n      try {\n        val detector=meshDetector ?: FaceMeshDetection.getClient(FaceMeshDetectorOptions.Builder().build()).also{meshDetector=it}\n        detector.process(image).addOnSuccessListener(mainExecutor){meshes->\n          if(token!=generation || !wantsFrames()){finish(WarpFaceState());return@addOnSuccessListener}\n          val mesh=chooseMesh(meshes)\n          if(mesh==null || !isMeshPoseSafe(mesh))fallback()\n          else finish(meshFaceState(mesh,bitmap.width,bitmap.height,rotation))\n        }.addOnFailureListener(mainExecutor){fallback()}\n      } catch(e:Exception){fallback();report(e)}\n    }\n  }\n  private fun emitFace(state:WarpFaceState,g:FrameGeometry){\n    val a=g.uprightToDisplay(state.faceCenterX-state.faceRadiusX,state.faceCenterY-state.faceRadiusY)\n    val b=g.uprightToDisplay(state.faceCenterX+state.faceRadiusX,state.faceCenterY+state.faceRadiusY)\n    val density=resources.displayMetrics.density\n    val bounds=mapOf("x" to minOf(a.first,b.first)*pixelWidth/density,"y" to minOf(a.second,b.second)*pixelHeight/density,\n      "width" to abs(b.first-a.first)*pixelWidth/density,"height" to abs(b.second-a.second)*pixelHeight/density)\n    onFaceState(mapOf("visible" to state.hasFace,"filter" to filter,"bounds" to bounds))\n  }\n  fun setSessionId(value:String){\n    if(sessionId==value)return\n    Registry.remove(sessionId,this);sessionId=value\n    if(attached)Registry.put(value,this)\n  }\n  fun setFacing(value:String){if(facing!=value){facing=value;invalidateFrames()}}\n  fun setFilter(value:String){if(filter!=value){filter=value;renderFailed=false;errorReported=false;invalidateFrames()}}\n  fun setActive(value:Boolean){if(active!=value){active=value;invalidateFrames();if(!value)closeDetectorsIfIdle()}}\n  private fun invalidateFrames(){\n    ++generation;ready=false;textureView.alpha=0f\n    // Leave the output connected. Filter changes do not create or stop a camera.\n    if(filter=="طبيعي" || !active || !foreground) {\n      closeDetectorsIfIdle()\n      stopRenderer()\n    }\n  }\n  private fun stopRenderer(){\n    val gl=renderer ?: return\n    if(rendererStopping)return\n    rendererStopping=true\n    gl.release{main.post{if(renderer===gl){renderer=null;rendererStopping=false}}}\n  }\n  private fun closeDetectorsIfIdle(){\n    if(detectorInFlight)return\n    meshDetector?.close();meshDetector=null\n    fallbackDetector?.close();fallbackDetector=null\n  }\n  private fun ensureRenderer():WarpRenderer?{\n    if(renderFailed || rendererStopping || !textureView.isAvailable)return null\n    return renderer ?: WarpRenderer(textureView.surfaceTexture ?: return null){error->main.post{\n      renderFailed=true;textureView.alpha=0f;ready=false;stopRenderer();report(error)\n    }}.also{renderer=it}\n  }\n  private fun report(error:Throwable){\n    if(!attached || errorReported)return\n    errorReported=true\n    android.util.Log.e("SawaFaceWarp","Frame processing failed",error)\n    onError(mapOf("message" to "تعذر معالجة الفلتر. أعد اختيار الفلتر للمحاولة.","engine" to "vision-camera-frames"))\n  }\n  override fun onLayout(changed:Boolean,left:Int,top:Int,right:Int,bottom:Int){\n    pixelWidth=max(1,right-left);pixelHeight=max(1,bottom-top)\n    textureView.layout(0,0,pixelWidth,pixelHeight)\n  }\n  override fun onSurfaceTextureAvailable(surface:SurfaceTexture,width:Int,height:Int){}\n  override fun onSurfaceTextureSizeChanged(surface:SurfaceTexture,width:Int,height:Int){invalidateFrames()}\n  override fun onSurfaceTextureUpdated(surface:SurfaceTexture){}\n  override fun onSurfaceTextureDestroyed(surface:SurfaceTexture):Boolean{\n    ++generation;ready=false\n    val gl=renderer;renderer=null;rendererStopping=false\n    if(gl!=null)gl.release{surface.release()} else surface.release()\n    return false // TextureView must NOT destroy a surface still used by the GL thread.\n  }\n  override fun onAttachedToWindow(){\n    super.onAttachedToWindow();attached=true;Registry.put(sessionId,this)\n    owner=appContext.currentActivity as? LifecycleOwner\n    owner?.lifecycle?.addObserver(this)\n  }\n  override fun onDetachedFromWindow(){\n    attached=false;Registry.remove(sessionId,this);++generation\n    owner?.lifecycle?.removeObserver(this);owner=null\n    closeDetectorsIfIdle()\n    // Texture destruction callback owns EGL teardown and SurfaceTexture release.\n    super.onDetachedFromWindow()\n  }\n  override fun onStop(owner:LifecycleOwner){foreground=false;invalidateFrames()}\n  override fun onStart(owner:LifecycleOwner){foreground=true;invalidateFrames()}\n\n  private fun chooseMesh(meshes: List<FaceMesh>): FaceMesh? = meshes.maxByOrNull {\n    it.boundingBox.width() * it.boundingBox.height()\n  }\n\n  private fun chooseFace(faces: List<Face>): Face? = faces.maxByOrNull { it.boundingBox.width() * it.boundingBox.height() }\n\n  private fun isPoseSafe(face: Face): Boolean {\n    // Extreme head poses make 2D warps look unstable. Disable deformation for\n    // that frame and keep the untouched camera image instead.\n    return abs(face.headEulerAngl'... 8866 more characters,
    expected: /localRenderer\.retainCameraSurface\(\)/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/v3-1-face-warp.test.mjs:67:1
✖ GPU renderer performs inverse pixel deformation instead of overlay-only drawing (0.404623ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /GL_TEXTURE_EXTERNAL_OES/. Input:
  
  'package com.sawaup.facewarp\n' +
    '\n' +
    'import android.graphics.Bitmap\n' +
    'import android.graphics.SurfaceTexture\n' +
    'import android.opengl.EGL14\n' +
    'import android.opengl.EGLConfig\n' +
    'import android.opengl.EGLContext\n' +
    'import android.opengl.EGLDisplay\n' +
    'import android.opengl.EGLSurface\n' +
    'import android.opengl.GLES20\n' +
    'import android.opengl.GLUtils\n' +
    'import android.os.Handler\n' +
    'import android.os.HandlerThread\n' +
    'import android.view.Surface\n' +
    'import java.nio.ByteBuffer\n' +
    'import java.nio.ByteOrder\n' +
    'import java.util.concurrent.atomic.AtomicBoolean\n' +
    'import kotlin.math.max\n' +
    '\n' +
    'internal data class WarpFaceState(\n' +
    '  val hasFace: Boolean = false,\n' +
    '  val leftEyeX: Float = 0.35f,\n' +
    '  val leftEyeY: Float = 0.40f,\n' +
    '  val leftEyeRadiusX: Float = 0.08f,\n' +
    '  val leftEyeRadiusY: Float = 0.045f,\n' +
    '  val rightEyeX: Float = 0.65f,\n' +
    '  val rightEyeY: Float = 0.40f,\n' +
    '  val rightEyeRadiusX: Float = 0.08f,\n' +
    '  val rightEyeRadiusY: Float = 0.045f,\n' +
    '  val noseX: Float = 0.50f,\n' +
    '  val noseY: Float = 0.53f,\n' +
    '  val noseRadiusX: Float = 0.06f,\n' +
    '  val noseRadiusY: Float = 0.085f,\n' +
    '  val mouthX: Float = 0.50f,\n' +
    '  val mouthY: Float = 0.68f,\n' +
    '  val mouthRadiusX: Float = 0.11f,\n' +
    '  val mouthRadiusY: Float = 0.055f,\n' +
    '  val mouthLeftX: Float = 0.42f,\n' +
    '  val mouthLeftY: Float = 0.68f,\n' +
    '  val mouthRightX: Float = 0.58f,\n' +
    '  val mouthRightY: Float = 0.68f,\n' +
    '  val faceCenterX: Float = 0.50f,\n' +
    '  val faceCenterY: Float = 0.52f,\n' +
    '  val faceRadiusX: Float = 0.25f,\n' +
    '  val faceRadiusY: Float = 0.34f,\n' +
    ')\n' +
    '\n' +
    'internal data class WarpParameters(\n' +
    '  val eye: Float = 0f,\n' +
    '  val nose: Float = 0f,\n' +
    '  val slim: Float = 0f,\n' +
    '  val lip: Float = 0f,\n' +
    '  val smile: Float = 0f,\n' +
    '  val beauty: Float = 0f,\n' +
    '  val brightness: Float = 0f,\n' +
    ') {\n' +
    '  companion object {\n' +
    '    fun forFilter(name: String): WarpParameters = when (name) {\n' +
    '      "صفاء" -> WarpParameters(beauty = 0.16f, brightness = 0.018f)\n' +
    '      "إشراقة" -> WarpParameters(beauty = 0.10f, brightness = 0.045f)\n' +
    '      "جمال ناعم" -> WarpParameters(eye = 0.055f, nose = 0.055f, slim = 0.045f, lip = 0.035f, beauty = 0.11f, brightness = 0.018f)\n' +
    '      "جمال+" -> WarpParameters(eye = 0.09f, nose = 0.09f, slim = 0.075f, lip = 0.055f, beauty = 0.16f, brightness = 0.025f)\n' +
    '      "عيون" -> WarpParameters(eye = 0.24f)\n' +
    '      "ابتسامة" -> WarpParameters(smile = 0.020f, lip = 0.04f)\n' +
    '      "ضحكة" -> WarpParameters(smile = 0.036f, lip = 0.075f)\n' +
    '      "وجه طفولي" -> WarpParameters(eye = 0.21f, nose = 0.10f, slim = -0.035f, lip = 0.045f, beauty = 0.08f)\n' +
    '      "وجه نحيف" -> WarpParameters(slim = 0.12f)\n' +
    '      "وجه ممتلئ" -> WarpParameters(slim = -0.105f)\n' +
    '      "أنف ناعم" -> WarpParameters(nose = 0.16f)\n' +
    '      "شفاه" -> WarpParameters(lip = 0.18f)\n' +
    '      else -> WarpParameters()\n' +
    '    }\n' +
    '  }\n' +
    '}\n' +
    '\n' +
    '\n' +
    '/** Renders copied VisionCamera frames. Owns no camera, ImageProxy or producer Surface. */\n' +
    'internal class WarpRenderer(\n' +
    '  output: SurfaceTexture,\n' +
    '  private val onError: (Throwable) -> Unit,\n' +
    ') {\n' +
    '  private val thread = HandlerThread("SawaFaceWarpGL").apply { start() }\n' +
    '  private val handler = Handler(thread.looper)\n' +
    '  private val closing = AtomicBoolean(false)\n' +
    '  private val terminated = AtomicBoolean(false)\n' +
    '  private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY\n' +
    '  private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT\n' +
    '  private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE\n' +
    '  private var outputSurface: Surface? = null\n' +
    '  private var texture = 0\n' +
    '  private var program = 0\n' +
    '  private var width = 1\n' +
    '  private var height = 1\n' +
    '  private var geometry = FrameGeometry.create(1,1,0,false,1,1)\n' +
    '  private var faceState = WarpFaceState()\n' +
    '  private var params = WarpParameters()\n' +
    '  private val vertexBuffer = ByteBuffer.allocateDirect(64).order(ByteOrder.nativeOrder()).asFloatBuffer().apply {\n' +
    '    put(floatArrayOf(-1f,-1f,0f,0f, 1f,-1f,1f,0f, -1f,1f,0f,1f, 1f,1f,1f,1f)); position(0)\n' +
    '  }\n' +
    '  init {\n' +
    '    handler.post {\n' +
    '      try { initEgl(output) } catch(t: Throwable) { onError(t); release() }\n' +
    '    }\n' +
    '  }\n' +
    '  fun draw(bitmap: Bitmap, transform: FrameGeometry, state: WarpFaceState, filter: String, done: (Boolean) -> Unit) {\n' +
    '    if(closing.get()) { bitmap.recycle(); done(false); return }\n' +
    '    if(!handler.post {\n' +
    '      var rendered=false\n' +
    '      try {\n' +
    '        if(!closing.get()) {\n' +
    '          width=transform.viewWidth; height=transform.viewHeight\n' +
    '          if(geometry != transform)faceState=WarpFaceState()\n' +
    '          geometry=transform; params=WarpParameters.forFilter(filter); setFace(state)\n' +
    '          render(bitmap); rendered=true\n' +
    '        }\n' +
    '      } catch(t: Throwable) { onError(t); release() }\n' +
    '      finally { bitmap.recycle(); done(rendered) }\n' +
    '    }) { bitmap.recycle(); done(false) }\n' +
    '  }\n' +
    '  // SurfaceTexture owner waits for this callback before destroying the output.\n' +
    '  @Synchronized fun release(done: () -> Unit = {}) {\n' +
    '    if(terminated.get()) { done(); return }\n' +
    '    closing.set(true)\n' +
    '    if(!handler.post {\n' +
    '      releaseInternal()\n' +
    '      terminated.set(true)\n' +
    '      done()\n' +
    '      thread.quitSafely()\n' +
    '    })done()\n' +
    '  }\n' +
    '  private fun initEgl(outTexture: SurfaceTexture) {\n' +
    '    eglDisplay=EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)\n' +
    '    check(eglDisplay!=EGL14.EGL_NO_DISPLAY)\n' +
    '    val version=IntArray(2)\n' +
    '    check(EGL14.eglInitialize(eglDisplay,version,0,version,1))\n' +
    '    val attrs=intArrayOf(EGL14.EGL_RED_SIZE,8,EGL14.EGL_GREEN_SIZE,8,EGL14.EGL_BLUE_SIZE,8,EGL14.EGL_ALPHA_SIZE,8,EGL14.EGL_RENDERABLE_TYPE,EGL14.EGL_OPENGL_ES2_BIT,EGL14.EGL_NONE)\n' +
    '    val configs=arrayOfNulls<EGLConfig>(1);val count=IntArray(1)\n' +
    '    check(EGL14.eglChooseConfig(eglDisplay,attrs,0,configs,0,1,count,0) && count[0]>0)\n' +
    '    eglContext=EGL14.eglCreateContext(eglDisplay,configs[0],EGL14.EGL_NO_CONTEXT,intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION,2,EGL14.EGL_NONE),0)\n' +
    '    check(eglContext!=EGL14.EGL_NO_CONTEXT)\n' +
    '    outputSurface=Surface(outTexture)\n' +
    '    eglSurface=EGL14.eglCreateWindowSurface(eglDisplay,configs[0],outputSurface,intArrayOf(EGL14.EGL_NONE),0)\n' +
    '    check(eglSurface!=EGL14.EGL_NO_SURFACE)\n' +
    '    check(EGL14.eglMakeCurrent(eglDisplay,eglSurface,eglSurface,eglContext))\n' +
    '    val ids=IntArray(1);GLES20.glGenTextures(1,ids,0);texture=ids[0]\n' +
    '    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,texture)\n' +
    '    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,GLES20.GL_TEXTURE_MIN_FILTER,GLES20.GL_LINEAR)\n' +
    '    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,GLES20.GL_TEXTURE_MAG_FILTER,GLES20.GL_LINEAR)\n' +
    '    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,GLES20.GL_TEXTURE_WRAP_S,GLES20.GL_CLAMP_TO_EDGE)\n' +
    '    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,GLES20.GL_TEXTURE_WRAP_T,GLES20.GL_CLAMP_TO_EDGE)\n' +
    '    program=buildProgram(VERTEX_SHADER,FRAGMENT_SHADER)\n' +
    '  }\n' +
    '  private fun render(bitmap: Bitmap) {\n' +
    '    check(EGL14.eglMakeCurrent(eglDisplay,eglSurface,eglSurface,eglContext))\n' +
    '    GLES20.glViewport(0,0,width,height)\n' +
    '    GLES20.glUseProgram(program)\n' +
    '    vertexBuffer.position(0)\n' +
    '    val aPos=GLES20.glGetAttribLocation(program,"aPosition")\n' +
    '    GLES20.glEnableVertexAttribArray(aPos);GLES20.glVertexAttribPointer(aPos,2,GLES20.GL_FLOAT,false,16,vertexBuffer)\n' +
    '    vertexBuffer.position(2)\n' +
    '    val aUv=GLES20.glGetAttribLocation(program,"aTexCoord")\n' +
    '    GLES20.glEnableVertexAttribArray(aUv);GLES20.glVertexAttribPointer(aUv,2,GLES20.GL_FLOAT,false,16,vertexBuffer)\n' +
    '    GLES20.glActiveTexture(GLES20.GL_TEXTURE0);GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,texture)\n' +
    '    GLUtils.texImage2D(GLES20.GL_TEXTURE_2D,0,bitmap,0)\n' +
    '    GLES20.glUniform1i(GLES20.glGetUniformLocation(program,"uCamera"),0)\n' +
    '    GLES20.glUniform1i(GLES20.glGetUniformLocation(program,"uRotation"),geometry.rotation/90)\n' +
    '    GLES20.glUniform1f(GLES20.glGetUniformLocation(program,"uMirror"),if(geometry.mirrored)1f else 0f)\n' +
    '    GLES20.glUniform4f(GLES20.glGetUniformLocation(program,"uCrop"),geometry.left,geometry.top,geometry.right,geometry.bottom)\n' +
    '      val f = faceState; val p = params\n' +
    '      GLES20.glUniform1f(GLES20.glGetUniformLocation(program,"uHasFace"), if (f.hasFace) 1f else 0f)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uLeftEye"),f.leftEyeX,f.leftEyeY)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uLeftEyeRadius"),f.leftEyeRadiusX,f.leftEyeRadiusY)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uRightEye"),f.rightEyeX,f.rightEyeY)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uRightEyeRadius"),f.rightEyeRadiusX,f.rightEyeRadiusY)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uNose"),f.noseX,f.noseY)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uNoseRadius"),f.noseRadiusX,f.noseRadiusY)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uMouth"),f.mouthX,f.mouthY)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uMouthRadius"),f.mouthRadiusX,f.mouthRadiusY)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uMouthLeft"),f.mouthLeftX,f.mouthLeftY)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uMouthRight"),f.mouthRightX,f.mouthRightY)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uFaceCenter"),f.faceCenterX,f.faceCenterY)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uFaceRadius"),f.faceRadiusX,f.faceRadiusY)\n' +
    '      GLES20.glUniform4f(GLES20.glGetUniformLocation(program,"uMorphA"),p.eye,p.nose,p.slim,p.lip)\n' +
    '      GLES20.glUniform4f(GLES20.glGetUniformLocation(program,"uMorphB"),p.smile,p.beauty,p.brightness,0f)\n' +
    '      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uPixel"),1f/max(1,width).toFloat(),1f/max(1,height).toFloat())\n' +
    '\n' +
    '\n' +
    '    GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP,0,4)\n' +
    '    GLES20.glDisableVertexAttribArray(aPos);GLES20.glDisableVertexAttribArray(aUv)\n' +
    '    check(EGL14.eglSwapBuffers(eglDisplay,eglSurface)) { "EGL swap failed" }\n' +
    '  }\n' +
    '  fun setFace(source: WarpFaceState) {\n' +
    '    // ML Kit landmarks arrive in upright full-frame coordinates. Convert them to\n' +
    '    // the actual cropped/mirrored display coordinates before smoothing.\n' +
    '    val cropW = max(0.001f, geometry.right - geometry.left)\n' +
    '    val cropH = max(0.001f, geometry.bottom - geometry.top)\n' +
    '    fun dx(x: Float): Float {\n' +
    '      val cropped = ((x - geometry.left) / cropW).coerceIn(-0.25f, 1.25f)\n' +
    '      return if (geometry.mirrored) 1f - cropped else cropped\n' +
    '    }\n' +
    '  '... 8658 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/v3-1-face-warp.test.mjs:68:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async Test.processPendingSubtests (node:internal/test_runner/test:960:7) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: 'package com.sawaup.facewarp\n\nimport android.graphics.Bitmap\nimport android.graphics.SurfaceTexture\nimport android.opengl.EGL14\nimport android.opengl.EGLConfig\nimport android.opengl.EGLContext\nimport android.opengl.EGLDisplay\nimport android.opengl.EGLSurface\nimport android.opengl.GLES20\nimport android.opengl.GLUtils\nimport android.os.Handler\nimport android.os.HandlerThread\nimport android.view.Surface\nimport java.nio.ByteBuffer\nimport java.nio.ByteOrder\nimport java.util.concurrent.atomic.AtomicBoolean\nimport kotlin.math.max\n\ninternal data class WarpFaceState(\n  val hasFace: Boolean = false,\n  val leftEyeX: Float = 0.35f,\n  val leftEyeY: Float = 0.40f,\n  val leftEyeRadiusX: Float = 0.08f,\n  val leftEyeRadiusY: Float = 0.045f,\n  val rightEyeX: Float = 0.65f,\n  val rightEyeY: Float = 0.40f,\n  val rightEyeRadiusX: Float = 0.08f,\n  val rightEyeRadiusY: Float = 0.045f,\n  val noseX: Float = 0.50f,\n  val noseY: Float = 0.53f,\n  val noseRadiusX: Float = 0.06f,\n  val noseRadiusY: Float = 0.085f,\n  val mouthX: Float = 0.50f,\n  val mouthY: Float = 0.68f,\n  val mouthRadiusX: Float = 0.11f,\n  val mouthRadiusY: Float = 0.055f,\n  val mouthLeftX: Float = 0.42f,\n  val mouthLeftY: Float = 0.68f,\n  val mouthRightX: Float = 0.58f,\n  val mouthRightY: Float = 0.68f,\n  val faceCenterX: Float = 0.50f,\n  val faceCenterY: Float = 0.52f,\n  val faceRadiusX: Float = 0.25f,\n  val faceRadiusY: Float = 0.34f,\n)\n\ninternal data class WarpParameters(\n  val eye: Float = 0f,\n  val nose: Float = 0f,\n  val slim: Float = 0f,\n  val lip: Float = 0f,\n  val smile: Float = 0f,\n  val beauty: Float = 0f,\n  val brightness: Float = 0f,\n) {\n  companion object {\n    fun forFilter(name: String): WarpParameters = when (name) {\n      "صفاء" -> WarpParameters(beauty = 0.16f, brightness = 0.018f)\n      "إشراقة" -> WarpParameters(beauty = 0.10f, brightness = 0.045f)\n      "جمال ناعم" -> WarpParameters(eye = 0.055f, nose = 0.055f, slim = 0.045f, lip = 0.035f, beauty = 0.11f, brightness = 0.018f)\n      "جمال+" -> WarpParameters(eye = 0.09f, nose = 0.09f, slim = 0.075f, lip = 0.055f, beauty = 0.16f, brightness = 0.025f)\n      "عيون" -> WarpParameters(eye = 0.24f)\n      "ابتسامة" -> WarpParameters(smile = 0.020f, lip = 0.04f)\n      "ضحكة" -> WarpParameters(smile = 0.036f, lip = 0.075f)\n      "وجه طفولي" -> WarpParameters(eye = 0.21f, nose = 0.10f, slim = -0.035f, lip = 0.045f, beauty = 0.08f)\n      "وجه نحيف" -> WarpParameters(slim = 0.12f)\n      "وجه ممتلئ" -> WarpParameters(slim = -0.105f)\n      "أنف ناعم" -> WarpParameters(nose = 0.16f)\n      "شفاه" -> WarpParameters(lip = 0.18f)\n      else -> WarpParameters()\n    }\n  }\n}\n\n\n/** Renders copied VisionCamera frames. Owns no camera, ImageProxy or producer Surface. */\ninternal class WarpRenderer(\n  output: SurfaceTexture,\n  private val onError: (Throwable) -> Unit,\n) {\n  private val thread = HandlerThread("SawaFaceWarpGL").apply { start() }\n  private val handler = Handler(thread.looper)\n  private val closing = AtomicBoolean(false)\n  private val terminated = AtomicBoolean(false)\n  private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY\n  private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT\n  private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE\n  private var outputSurface: Surface? = null\n  private var texture = 0\n  private var program = 0\n  private var width = 1\n  private var height = 1\n  private var geometry = FrameGeometry.create(1,1,0,false,1,1)\n  private var faceState = WarpFaceState()\n  private var params = WarpParameters()\n  private val vertexBuffer = ByteBuffer.allocateDirect(64).order(ByteOrder.nativeOrder()).asFloatBuffer().apply {\n    put(floatArrayOf(-1f,-1f,0f,0f, 1f,-1f,1f,0f, -1f,1f,0f,1f, 1f,1f,1f,1f)); position(0)\n  }\n  init {\n    handler.post {\n      try { initEgl(output) } catch(t: Throwable) { onError(t); release() }\n    }\n  }\n  fun draw(bitmap: Bitmap, transform: FrameGeometry, state: WarpFaceState, filter: String, done: (Boolean) -> Unit) {\n    if(closing.get()) { bitmap.recycle(); done(false); return }\n    if(!handler.post {\n      var rendered=false\n      try {\n        if(!closing.get()) {\n          width=transform.viewWidth; height=transform.viewHeight\n          if(geometry != transform)faceState=WarpFaceState()\n          geometry=transform; params=WarpParameters.forFilter(filter); setFace(state)\n          render(bitmap); rendered=true\n        }\n      } catch(t: Throwable) { onError(t); release() }\n      finally { bitmap.recycle(); done(rendered) }\n    }) { bitmap.recycle(); done(false) }\n  }\n  // SurfaceTexture owner waits for this callback before destroying the output.\n  @Synchronized fun release(done: () -> Unit = {}) {\n    if(terminated.get()) { done(); return }\n    closing.set(true)\n    if(!handler.post {\n      releaseInternal()\n      terminated.set(true)\n      done()\n      thread.quitSafely()\n    })done()\n  }\n  private fun initEgl(outTexture: SurfaceTexture) {\n    eglDisplay=EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)\n    check(eglDisplay!=EGL14.EGL_NO_DISPLAY)\n    val version=IntArray(2)\n    check(EGL14.eglInitialize(eglDisplay,version,0,version,1))\n    val attrs=intArrayOf(EGL14.EGL_RED_SIZE,8,EGL14.EGL_GREEN_SIZE,8,EGL14.EGL_BLUE_SIZE,8,EGL14.EGL_ALPHA_SIZE,8,EGL14.EGL_RENDERABLE_TYPE,EGL14.EGL_OPENGL_ES2_BIT,EGL14.EGL_NONE)\n    val configs=arrayOfNulls<EGLConfig>(1);val count=IntArray(1)\n    check(EGL14.eglChooseConfig(eglDisplay,attrs,0,configs,0,1,count,0) && count[0]>0)\n    eglContext=EGL14.eglCreateContext(eglDisplay,configs[0],EGL14.EGL_NO_CONTEXT,intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION,2,EGL14.EGL_NONE),0)\n    check(eglContext!=EGL14.EGL_NO_CONTEXT)\n    outputSurface=Surface(outTexture)\n    eglSurface=EGL14.eglCreateWindowSurface(eglDisplay,configs[0],outputSurface,intArrayOf(EGL14.EGL_NONE),0)\n    check(eglSurface!=EGL14.EGL_NO_SURFACE)\n    check(EGL14.eglMakeCurrent(eglDisplay,eglSurface,eglSurface,eglContext))\n    val ids=IntArray(1);GLES20.glGenTextures(1,ids,0);texture=ids[0]\n    GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,texture)\n    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,GLES20.GL_TEXTURE_MIN_FILTER,GLES20.GL_LINEAR)\n    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,GLES20.GL_TEXTURE_MAG_FILTER,GLES20.GL_LINEAR)\n    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,GLES20.GL_TEXTURE_WRAP_S,GLES20.GL_CLAMP_TO_EDGE)\n    GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D,GLES20.GL_TEXTURE_WRAP_T,GLES20.GL_CLAMP_TO_EDGE)\n    program=buildProgram(VERTEX_SHADER,FRAGMENT_SHADER)\n  }\n  private fun render(bitmap: Bitmap) {\n    check(EGL14.eglMakeCurrent(eglDisplay,eglSurface,eglSurface,eglContext))\n    GLES20.glViewport(0,0,width,height)\n    GLES20.glUseProgram(program)\n    vertexBuffer.position(0)\n    val aPos=GLES20.glGetAttribLocation(program,"aPosition")\n    GLES20.glEnableVertexAttribArray(aPos);GLES20.glVertexAttribPointer(aPos,2,GLES20.GL_FLOAT,false,16,vertexBuffer)\n    vertexBuffer.position(2)\n    val aUv=GLES20.glGetAttribLocation(program,"aTexCoord")\n    GLES20.glEnableVertexAttribArray(aUv);GLES20.glVertexAttribPointer(aUv,2,GLES20.GL_FLOAT,false,16,vertexBuffer)\n    GLES20.glActiveTexture(GLES20.GL_TEXTURE0);GLES20.glBindTexture(GLES20.GL_TEXTURE_2D,texture)\n    GLUtils.texImage2D(GLES20.GL_TEXTURE_2D,0,bitmap,0)\n    GLES20.glUniform1i(GLES20.glGetUniformLocation(program,"uCamera"),0)\n    GLES20.glUniform1i(GLES20.glGetUniformLocation(program,"uRotation"),geometry.rotation/90)\n    GLES20.glUniform1f(GLES20.glGetUniformLocation(program,"uMirror"),if(geometry.mirrored)1f else 0f)\n    GLES20.glUniform4f(GLES20.glGetUniformLocation(program,"uCrop"),geometry.left,geometry.top,geometry.right,geometry.bottom)\n      val f = faceState; val p = params\n      GLES20.glUniform1f(GLES20.glGetUniformLocation(program,"uHasFace"), if (f.hasFace) 1f else 0f)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uLeftEye"),f.leftEyeX,f.leftEyeY)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uLeftEyeRadius"),f.leftEyeRadiusX,f.leftEyeRadiusY)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uRightEye"),f.rightEyeX,f.rightEyeY)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uRightEyeRadius"),f.rightEyeRadiusX,f.rightEyeRadiusY)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uNose"),f.noseX,f.noseY)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uNoseRadius"),f.noseRadiusX,f.noseRadiusY)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uMouth"),f.mouthX,f.mouthY)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uMouthRadius"),f.mouthRadiusX,f.mouthRadiusY)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uMouthLeft"),f.mouthLeftX,f.mouthLeftY)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uMouthRight"),f.mouthRightX,f.mouthRightY)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uFaceCenter"),f.faceCenterX,f.faceCenterY)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uFaceRadius"),f.faceRadiusX,f.faceRadiusY)\n      GLES20.glUniform4f(GLES20.glGetUniformLocation(program,"uMorphA"),p.eye,p.nose,p.slim,p.lip)\n      GLES20.glUniform4f(GLES20.glGetUniformLocation(program,"uMorphB"),p.smile,p.beauty,p.brightness,0f)\n      GLES20.glUniform2f(GLES20.glGetUniformLocation(program,"uPixel"),1f/max(1,width).toFloat(),1f/max(1,height).toFloat())\n\n\n    GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP,0,4)\n    GLES20.glDisableVertexAttribArray(aPos);GLES20.glDisableVertexAttribArray(aUv)\n    check(EGL14.eglSwapBuffers(eglDisplay,eglSurface)) { "EGL swap failed" }\n  }\n  fun setFace(source: WarpFaceState) {\n    // ML Kit landmarks arrive in upright full-frame coordinates. Convert them to\n    // the actual cropped/mirrored display coordinates before smoothing.\n    val cropW = max(0.001f, geometry.right - geometry.left)\n    val cropH = max(0.001f, geometry.bottom - geometry.top)\n    fun dx(x: Float): Float {\n      val cropped = ((x - geometry.left) / cropW).coerceIn(-0.25f, 1.25f)\n      return if (geometry.mirrored) 1f - cropped else cropped\n    }\n  '... 8658 more characters,
    expected: /GL_TEXTURE_EXTERNAL_OES/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/v3-1-face-warp.test.mjs:99:1
✖ warp failure fails open to the stable VisionCamera camera path (0.631365ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /const nativeWarpActive = wantsNativeWarp && !warpDisabled/. Input:
  
  "import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';\n" +
    "import { AppState, Platform, StyleSheet, Text, View } from 'react-native';\n" +
    "import { Paths, File } from 'expo-file-system';\n" +
    "import { RecordingPresets, setAudioModeAsync, useAudioRecorder } from 'expo-audio';\n" +
    "import { RecordingView, useViewRecorder } from 'react-native-view-recorder';\n" +
    "import { Camera, useCameraDevice, usePhotoOutput, useVideoOutput } from 'react-native-vision-camera';\n" +
    "import { Camera as FaceCamera } from 'react-native-vision-camera-face-detector';\n" +
    "import SawaFaceWarpView, { createSawaWarpOutput, isFaceWarpNativeAvailable } from '../modules/sawaup-face-warp/src';\n" +
    '\n' +
    "const TEAL = '#2ED2C5';\n" +
    "const PEACH = '#FFB680';\n" +
    '\n' +
    'const LIVE_FILTERS = new Set([\n' +
    "  'صفاء','إشراقة','جمال ناعم','جمال+','عيون','ابتسامة','ضحكة','وجه طفولي','وجه نحيف','وجه ممتلئ',\n" +
    "  'أنف ناعم','شفاه','نمش','وردي','نجوم','مرح','كرتوني','نيون أثر','توقيع Sawa Up',\n" +
    ']);\n' +
    '\n' +
    '// These filters require geometric deformation of camera pixels. On Android they\n' +
    '// use an attached VisionCamera frame output. Filter changes never mount another\n' +
    '// camera owner; the native view only detects/deforms/renders copied frames.\n' +
    'const MORPH_FILTERS = new Set([\n' +
    "  'صفاء','إشراقة','جمال ناعم','جمال+','عيون','ابتسامة','ضحكة','وجه طفولي','وجه نحيف','وجه ممتلئ','أنف ناعم','شفاه',\n" +
    ']);\n' +
    '\n' +
    'function clampBox(bounds) {\n' +
    '  if (!bounds) return null;\n' +
    '  const x = Number(bounds.x ?? bounds.left ?? 0);\n' +
    '  const y = Number(bounds.y ?? bounds.top ?? 0);\n' +
    '  const width = Number(bounds.width ?? 0);\n' +
    '  const height = Number(bounds.height ?? 0);\n' +
    '  if (![x,y,width,height].every(Number.isFinite) || width < 20 || height < 20) return null;\n' +
    '  return { x, y, width, height };\n' +
    '}\n' +
    '\n' +
    'function FaceEffect({ face, filter }) {\n' +
    '  const box = clampBox(face?.bounds);\n' +
    '  if (!box || !LIVE_FILTERS.has(filter)) return null;\n' +
    '  const { x, y, width:w, height:h } = box;\n' +
    "  const faceStyle = { position:'absolute', left:x, top:y, width:w, height:h, borderRadius:Math.max(18,w*.42) };\n" +
    '  const eyeSize = Math.max(12, Math.min(34, w*.16));\n' +
    '  const cheek = Math.max(18, Math.min(40, w*.22));\n' +
    '\n' +
    `  if (filter === 'صفاء') return <View pointerEvents="none" style={[faceStyle, fx.softSkin]} />;\n` +
    `  if (filter === 'إشراقة') return <View pointerEvents="none" style={[faceStyle,fx.faceGlow]}/>;\n` +
    `  if (filter === 'جمال ناعم' || filter === 'جمال+') return <View pointerEvents="none" style={[faceStyle,filter==='جمال+'?fx.beautyPlus:fx.beautySoft]}>\n` +
    '    <View style={[fx.eyeGlow,{left:w*.20,top:h*.32,width:eyeSize,height:eyeSize}]}/><View style={[fx.eyeGlow,{right:w*.20,top:h*.32,width:eyeSize,height:eyeSize}]}/>\n' +
    '    <View style={[fx.lipTint,{left:w*.34,top:h*.67,width:w*.32,height:Math.max(7,h*.055)}]}/>\n' +
    '  </View>;\n' +
    `  if (filter === 'عيون') return <View pointerEvents="none" style={faceStyle}><View style={[fx.eyeRing,{left:w*.16,top:h*.28,width:eyeSize*1.32,height:eyeSize*1.05}]}/><View style={[fx.eyeRing,{right:w*.16,top:h*.28,width:eyeSize*1.32,height:eyeSize*1.05}]}/></View>;\n` +
    `  if (filter === 'ابتسامة' || filter === 'ضحكة') return <View pointerEvents="none" style={faceStyle}><View style={[fx.smile,{left:w*.31,top:h*.65,width:w*.38,height:filter==='ضحكة'?h*.13:h*.09,borderBottomWidth:filter==='ضحكة'?4:3}]}/>{filter==='ضحكة'?<><View style={[fx.cheek,{left:w*.12,top:h*.54,width:cheek,height:cheek}]}/><View style={[fx.cheek,{right:w*.12,top:h*.54,width:cheek,height:cheek}]}/></>:null}</View>;\n` +
    `  if (filter === 'وجه طفولي') return <View pointerEvents="none" style={[faceStyle,fx.babyFace]}><View style={[fx.bigEye,{left:w*.16,top:h*.27,width:eyeSize*1.4,height:eyeSize*1.2}]}/><View style={[fx.bigEye,{right:w*.16,top:h*.27,width:eyeSize*1.4,height:eyeSize*1.2}]}/><View style={[fx.cheek,{left:w*.10,top:h*.55,width:cheek,height:cheek}]}/><View style={[fx.cheek,{right:w*.10,top:h*.55,width:cheek,height:cheek}]}/></View>;\n` +
    `  if (filter === 'وجه نحيف') return <View pointerEvents="none" style={faceStyle}><View style={[fx.sideShade,{left:-w*.03,top:h*.20,width:w*.19,height:h*.58,transform:[{rotate:'8deg'}]}]}/><View style={[fx.sideShade,{right:-w*.03,top:h*.20,width:w*.19,height:h*.58,transform:[{rotate:'-8deg'}]}]}/><View style={[fx.jawLine,{left:w*.18,top:h*.72,width:w*.64,height:h*.20}]}/></View>;\n` +
    `  if (filter === 'وجه ممتلئ') return <View pointerEvents="none" style={faceStyle}><View style={[fx.fullCheek,{left:w*.05,top:h*.48,width:w*.30,height:h*.25}]}/><View style={[fx.fullCheek,{right:w*.05,top:h*.48,width:w*.30,height:h*.25}]}/></View>;\n` +
    `  if (filter === 'أنف ناعم') return <View pointerEvents="none" style={faceStyle}><View style={[fx.noseLine,{left:w*.485,top:h*.40,height:h*.25}]}/><View style={[fx.noseDot,{left:w*.47,top:h*.63}]}/></View>;\n` +
    `  if (filter === 'شفاه') return <View pointerEvents="none" style={faceStyle}><View style={[fx.lipTintStrong,{left:w*.31,top:h*.66,width:w*.38,height:h*.08}]}/></View>;\n` +
    `  if (filter === 'نمش') return <View pointerEvents="none" style={faceStyle}>{Array.from({length:14}).map((_,i)=><View key={i} style={[fx.freckle,{left:w*(.27+(i%7)*.075),top:h*(.49+(i%2)*.035)}]}/>)}</View>;\n` +
    `  if (filter === 'وردي') return <View pointerEvents="none" style={faceStyle}><View style={[fx.pinkCheek,{left:w*.08,top:h*.52,width:w*.28,height:h*.16}]}/><View style={[fx.pinkCheek,{right:w*.08,top:h*.52,width:w*.28,height:h*.16}]}/></View>;\n` +
    `  if (filter === 'نجوم') return <View pointerEvents="none" style={faceStyle}><Text style={[fx.star,{left:w*.03,top:h*.05}]}>✦</Text><Text style={[fx.starSmall,{right:w*.02,top:h*.19}]}>✧</Text><Text style={[fx.starSmall,{left:w*.08,top:h*.68}]}>✦</Text></View>;\n` +
    `  if (filter === 'مرح') return <View pointerEvents="none" style={faceStyle}><View style={[fx.funDot,{left:w*.12,top:h*.18,backgroundColor:PEACH}]}/><View style={[fx.funDot,{right:w*.12,top:h*.18,backgroundColor:TEAL}]}/><View style={[fx.funMouth,{left:w*.28,top:h*.68,width:w*.44,height:h*.13}]}/></View>;\n` +
    `  if (filter === 'كرتوني') return <View pointerEvents="none" style={[faceStyle,fx.cartoon]}><View style={[fx.cartoonEye,{left:w*.16,top:h*.28,width:eyeSize,height:eyeSize}]}/><View style={[fx.cartoonEye,{right:w*.16,top:h*.28,width:eyeSize,height:eyeSize}]}/><View style={[fx.cartoonMouth,{left:w*.34,top:h*.68,width:w*.32,height:h*.08}]}/></View>;\n` +
    `  if (filter === 'نيون أثر') return <View pointerEvents="none" style={[faceStyle,fx.neonFace]}><View style={[fx.neonLine,{left:w*.12,top:h*.31,width:w*.30,borderColor:TEAL}]}/><View style={[fx.neonLine,{right:w*.12,top:h*.31,width:w*.30,borderColor:PEACH}]}/><View style={[fx.neonMouth,{left:w*.32,top:h*.67,width:w*.36,height:h*.08}]}/></View>;\n` +
    `  if (filter === 'توقيع Sawa Up') return <View pointerEvents="none" style={faceStyle}><View style={[fx.signature,{left:w*.08,top:h*.08}]}><View style={[fx.sigDot,{backgroundColor:TEAL}]}/><View style={[fx.sigDot,{backgroundColor:PEACH,marginLeft:-5,marginTop:6}]}/></View><Text style={[fx.signatureText,{right:w*.04,top:h*.80}]}>Sawa Up</Text></View>;\n` +
    '  return null;\n' +
    '}\n' +
    '\n' +
    "const AtharLiveCamera = forwardRef(function AtharLiveCamera({ active=true, facing='back', torch=false, mode='picture', microphoneGranted=false, filter='طبيعي', onReady, onNotReady, onError }, ref) {\n" +
    '  const device = useCameraDevice(facing);\n' +
    '  const visionRef = useRef(null), startedRef = useRef(false), appliedTorchRef = useRef(false);\n' +
    '  const [started,setStarted]=useState(false), [screenFlash,setScreenFlash]=useState(false);\n' +
    "  const [appActive,setAppActive]=useState(AppState.currentState==='active');\n" +
    '  const cameraActive=active && appActive;\n' +
    '  const callbackRef=useRef({onReady,onNotReady,onError}); callbackRef.current={onReady,onNotReady,onError};\n' +
    '  const errorSeenRef=useRef(new Set());\n' +
    '  const handleError=useCallback(error=>{\n' +
    '    const message=String(error?.message||error);\n' +
    '    if(errorSeenRef.current.has(message))return;\n' +
    '    errorSeenRef.current.add(message);\n' +
    "    callbackRef.current.onError?.({message:'تعذر تشغيل الكاميرا. أغلقها وافتحها مجددًا.',cause:message});\n" +
    '  },[]);\n' +
    '  const handleStarted=useCallback(()=>{startedRef.current=true;setStarted(true);errorSeenRef.current.clear();callbackRef.current.onReady?.();},[]);\n' +
    '  const handleStopped=useCallback(()=>{startedRef.current=false;setStarted(false);callbackRef.current.onNotReady?.();},[]);\n' +
    "  useEffect(()=>{const sub=AppState.addEventListener('change',value=>setAppActive(value==='active'));return()=>sub.remove();},[]);\n" +
    "  const photoOutput = usePhotoOutput({ qualityPrioritization: 'balanced', containerFormat: 'jpeg' });\n" +
    "  const videoOutput = useVideoOutput({ enableAudio: !!microphoneGranted, fileType: 'mp4' });\n" +
    '  const viewRecorder = useViewRecorder();\n' +
    '  const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);\n' +
    '  const nativeRecorderRef = useRef(null);\n' +
    '  const filteredRecordPromiseRef = useRef(null);\n' +
    '  const filteredAbortRef = useRef(null);\n' +
    '  const filteredTimeoutRef = useRef(null);\n' +
    '  const filteredRecordingRef = useRef(false);\n' +
    '  const [face,setFace]=useState(null);\n' +
    '  const [warpError,setWarpError]=useState(false);\n' +
    '  const pipelineId=useRef(`athar-${Date.now()}-${Math.random().toString(36).slice(2)}`).current;\n' +
    "  const pipelineAvailable=Platform.OS==='android' && isFaceWarpNativeAvailable();\n" +
    '  const warpOutput=useMemo(()=>pipelineAvailable?createSawaWarpOutput(pipelineId):null,[pipelineAvailable,pipelineId]);\n' +
    '  useEffect(()=>()=>{warpOutput?.dispose?.();},[warpOutput]);\n' +
    '  const [warpReady,setWarpReady]=useState(false);\n' +
    '  const [warpFaceVisible,setWarpFaceVisible]=useState(false);\n' +
    '  const lastFaceUpdate=useRef(0);\n' +
    '\n' +
    "  const outputs = useMemo(() => [...(mode === 'video' ? [videoOutput, photoOutput] : [photoOutput]),...(warpOutput?[warpOutput]:[])], [mode,photoOutput,videoOutput,warpOutput]);\n" +
    "  const hasLiveEffect = filter !== 'طبيعي';\n" +
    '  const nativeWarpActive = pipelineAvailable && MORPH_FILTERS.has(filter);\n' +
    '  const cameraComponent = pipelineAvailable ? Camera : FaceCamera;\n' +
    '  const CameraComponent = cameraComponent;\n' +
    '\n' +
    '  useEffect(()=>{\n' +
    '    setWarpError(false);setWarpReady(false);setWarpFaceVisible(false);setFace(null);\n' +
    '    if(nativeWarpActive)ca'... 13675 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/v3-1-face-warp.test.mjs:100:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async Test.processPendingSubtests (node:internal/test_runner/test:960:7) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: 'import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from \'react\';\nimport { AppState, Platform, StyleSheet, Text, View } from \'react-native\';\nimport { Paths, File } from \'expo-file-system\';\nimport { RecordingPresets, setAudioModeAsync, useAudioRecorder } from \'expo-audio\';\nimport { RecordingView, useViewRecorder } from \'react-native-view-recorder\';\nimport { Camera, useCameraDevice, usePhotoOutput, useVideoOutput } from \'react-native-vision-camera\';\nimport { Camera as FaceCamera } from \'react-native-vision-camera-face-detector\';\nimport SawaFaceWarpView, { createSawaWarpOutput, isFaceWarpNativeAvailable } from \'../modules/sawaup-face-warp/src\';\n\nconst TEAL = \'#2ED2C5\';\nconst PEACH = \'#FFB680\';\n\nconst LIVE_FILTERS = new Set([\n  \'صفاء\',\'إشراقة\',\'جمال ناعم\',\'جمال+\',\'عيون\',\'ابتسامة\',\'ضحكة\',\'وجه طفولي\',\'وجه نحيف\',\'وجه ممتلئ\',\n  \'أنف ناعم\',\'شفاه\',\'نمش\',\'وردي\',\'نجوم\',\'مرح\',\'كرتوني\',\'نيون أثر\',\'توقيع Sawa Up\',\n]);\n\n// These filters require geometric deformation of camera pixels. On Android they\n// use an attached VisionCamera frame output. Filter changes never mount another\n// camera owner; the native view only detects/deforms/renders copied frames.\nconst MORPH_FILTERS = new Set([\n  \'صفاء\',\'إشراقة\',\'جمال ناعم\',\'جمال+\',\'عيون\',\'ابتسامة\',\'ضحكة\',\'وجه طفولي\',\'وجه نحيف\',\'وجه ممتلئ\',\'أنف ناعم\',\'شفاه\',\n]);\n\nfunction clampBox(bounds) {\n  if (!bounds) return null;\n  const x = Number(bounds.x ?? bounds.left ?? 0);\n  const y = Number(bounds.y ?? bounds.top ?? 0);\n  const width = Number(bounds.width ?? 0);\n  const height = Number(bounds.height ?? 0);\n  if (![x,y,width,height].every(Number.isFinite) || width < 20 || height < 20) return null;\n  return { x, y, width, height };\n}\n\nfunction FaceEffect({ face, filter }) {\n  const box = clampBox(face?.bounds);\n  if (!box || !LIVE_FILTERS.has(filter)) return null;\n  const { x, y, width:w, height:h } = box;\n  const faceStyle = { position:\'absolute\', left:x, top:y, width:w, height:h, borderRadius:Math.max(18,w*.42) };\n  const eyeSize = Math.max(12, Math.min(34, w*.16));\n  const cheek = Math.max(18, Math.min(40, w*.22));\n\n  if (filter === \'صفاء\') return <View pointerEvents="none" style={[faceStyle, fx.softSkin]} />;\n  if (filter === \'إشراقة\') return <View pointerEvents="none" style={[faceStyle,fx.faceGlow]}/>;\n  if (filter === \'جمال ناعم\' || filter === \'جمال+\') return <View pointerEvents="none" style={[faceStyle,filter===\'جمال+\'?fx.beautyPlus:fx.beautySoft]}>\n    <View style={[fx.eyeGlow,{left:w*.20,top:h*.32,width:eyeSize,height:eyeSize}]}/><View style={[fx.eyeGlow,{right:w*.20,top:h*.32,width:eyeSize,height:eyeSize}]}/>\n    <View style={[fx.lipTint,{left:w*.34,top:h*.67,width:w*.32,height:Math.max(7,h*.055)}]}/>\n  </View>;\n  if (filter === \'عيون\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.eyeRing,{left:w*.16,top:h*.28,width:eyeSize*1.32,height:eyeSize*1.05}]}/><View style={[fx.eyeRing,{right:w*.16,top:h*.28,width:eyeSize*1.32,height:eyeSize*1.05}]}/></View>;\n  if (filter === \'ابتسامة\' || filter === \'ضحكة\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.smile,{left:w*.31,top:h*.65,width:w*.38,height:filter===\'ضحكة\'?h*.13:h*.09,borderBottomWidth:filter===\'ضحكة\'?4:3}]}/>{filter===\'ضحكة\'?<><View style={[fx.cheek,{left:w*.12,top:h*.54,width:cheek,height:cheek}]}/><View style={[fx.cheek,{right:w*.12,top:h*.54,width:cheek,height:cheek}]}/></>:null}</View>;\n  if (filter === \'وجه طفولي\') return <View pointerEvents="none" style={[faceStyle,fx.babyFace]}><View style={[fx.bigEye,{left:w*.16,top:h*.27,width:eyeSize*1.4,height:eyeSize*1.2}]}/><View style={[fx.bigEye,{right:w*.16,top:h*.27,width:eyeSize*1.4,height:eyeSize*1.2}]}/><View style={[fx.cheek,{left:w*.10,top:h*.55,width:cheek,height:cheek}]}/><View style={[fx.cheek,{right:w*.10,top:h*.55,width:cheek,height:cheek}]}/></View>;\n  if (filter === \'وجه نحيف\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.sideShade,{left:-w*.03,top:h*.20,width:w*.19,height:h*.58,transform:[{rotate:\'8deg\'}]}]}/><View style={[fx.sideShade,{right:-w*.03,top:h*.20,width:w*.19,height:h*.58,transform:[{rotate:\'-8deg\'}]}]}/><View style={[fx.jawLine,{left:w*.18,top:h*.72,width:w*.64,height:h*.20}]}/></View>;\n  if (filter === \'وجه ممتلئ\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.fullCheek,{left:w*.05,top:h*.48,width:w*.30,height:h*.25}]}/><View style={[fx.fullCheek,{right:w*.05,top:h*.48,width:w*.30,height:h*.25}]}/></View>;\n  if (filter === \'أنف ناعم\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.noseLine,{left:w*.485,top:h*.40,height:h*.25}]}/><View style={[fx.noseDot,{left:w*.47,top:h*.63}]}/></View>;\n  if (filter === \'شفاه\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.lipTintStrong,{left:w*.31,top:h*.66,width:w*.38,height:h*.08}]}/></View>;\n  if (filter === \'نمش\') return <View pointerEvents="none" style={faceStyle}>{Array.from({length:14}).map((_,i)=><View key={i} style={[fx.freckle,{left:w*(.27+(i%7)*.075),top:h*(.49+(i%2)*.035)}]}/>)}</View>;\n  if (filter === \'وردي\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.pinkCheek,{left:w*.08,top:h*.52,width:w*.28,height:h*.16}]}/><View style={[fx.pinkCheek,{right:w*.08,top:h*.52,width:w*.28,height:h*.16}]}/></View>;\n  if (filter === \'نجوم\') return <View pointerEvents="none" style={faceStyle}><Text style={[fx.star,{left:w*.03,top:h*.05}]}>✦</Text><Text style={[fx.starSmall,{right:w*.02,top:h*.19}]}>✧</Text><Text style={[fx.starSmall,{left:w*.08,top:h*.68}]}>✦</Text></View>;\n  if (filter === \'مرح\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.funDot,{left:w*.12,top:h*.18,backgroundColor:PEACH}]}/><View style={[fx.funDot,{right:w*.12,top:h*.18,backgroundColor:TEAL}]}/><View style={[fx.funMouth,{left:w*.28,top:h*.68,width:w*.44,height:h*.13}]}/></View>;\n  if (filter === \'كرتوني\') return <View pointerEvents="none" style={[faceStyle,fx.cartoon]}><View style={[fx.cartoonEye,{left:w*.16,top:h*.28,width:eyeSize,height:eyeSize}]}/><View style={[fx.cartoonEye,{right:w*.16,top:h*.28,width:eyeSize,height:eyeSize}]}/><View style={[fx.cartoonMouth,{left:w*.34,top:h*.68,width:w*.32,height:h*.08}]}/></View>;\n  if (filter === \'نيون أثر\') return <View pointerEvents="none" style={[faceStyle,fx.neonFace]}><View style={[fx.neonLine,{left:w*.12,top:h*.31,width:w*.30,borderColor:TEAL}]}/><View style={[fx.neonLine,{right:w*.12,top:h*.31,width:w*.30,borderColor:PEACH}]}/><View style={[fx.neonMouth,{left:w*.32,top:h*.67,width:w*.36,height:h*.08}]}/></View>;\n  if (filter === \'توقيع Sawa Up\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.signature,{left:w*.08,top:h*.08}]}><View style={[fx.sigDot,{backgroundColor:TEAL}]}/><View style={[fx.sigDot,{backgroundColor:PEACH,marginLeft:-5,marginTop:6}]}/></View><Text style={[fx.signatureText,{right:w*.04,top:h*.80}]}>Sawa Up</Text></View>;\n  return null;\n}\n\nconst AtharLiveCamera = forwardRef(function AtharLiveCamera({ active=true, facing=\'back\', torch=false, mode=\'picture\', microphoneGranted=false, filter=\'طبيعي\', onReady, onNotReady, onError }, ref) {\n  const device = useCameraDevice(facing);\n  const visionRef = useRef(null), startedRef = useRef(false), appliedTorchRef = useRef(false);\n  const [started,setStarted]=useState(false), [screenFlash,setScreenFlash]=useState(false);\n  const [appActive,setAppActive]=useState(AppState.currentState===\'active\');\n  const cameraActive=active && appActive;\n  const callbackRef=useRef({onReady,onNotReady,onError}); callbackRef.current={onReady,onNotReady,onError};\n  const errorSeenRef=useRef(new Set());\n  const handleError=useCallback(error=>{\n    const message=String(error?.message||error);\n    if(errorSeenRef.current.has(message))return;\n    errorSeenRef.current.add(message);\n    callbackRef.current.onError?.({message:\'تعذر تشغيل الكاميرا. أغلقها وافتحها مجددًا.\',cause:message});\n  },[]);\n  const handleStarted=useCallback(()=>{startedRef.current=true;setStarted(true);errorSeenRef.current.clear();callbackRef.current.onReady?.();},[]);\n  const handleStopped=useCallback(()=>{startedRef.current=false;setStarted(false);callbackRef.current.onNotReady?.();},[]);\n  useEffect(()=>{const sub=AppState.addEventListener(\'change\',value=>setAppActive(value===\'active\'));return()=>sub.remove();},[]);\n  const photoOutput = usePhotoOutput({ qualityPrioritization: \'balanced\', containerFormat: \'jpeg\' });\n  const videoOutput = useVideoOutput({ enableAudio: !!microphoneGranted, fileType: \'mp4\' });\n  const viewRecorder = useViewRecorder();\n  const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);\n  const nativeRecorderRef = useRef(null);\n  const filteredRecordPromiseRef = useRef(null);\n  const filteredAbortRef = useRef(null);\n  const filteredTimeoutRef = useRef(null);\n  const filteredRecordingRef = useRef(false);\n  const [face,setFace]=useState(null);\n  const [warpError,setWarpError]=useState(false);\n  const pipelineId=useRef(`athar-${Date.now()}-${Math.random().toString(36).slice(2)}`).current;\n  const pipelineAvailable=Platform.OS===\'android\' && isFaceWarpNativeAvailable();\n  const warpOutput=useMemo(()=>pipelineAvailable?createSawaWarpOutput(pipelineId):null,[pipelineAvailable,pipelineId]);\n  useEffect(()=>()=>{warpOutput?.dispose?.();},[warpOutput]);\n  const [warpReady,setWarpReady]=useState(false);\n  const [warpFaceVisible,setWarpFaceVisible]=useState(false);\n  const lastFaceUpdate=useRef(0);\n\n  const outputs = useMemo(() => [...(mode === \'video\' ? [videoOutput, photoOutput] : [photoOutput]),...(warpOutput?[warpOutput]:[])], [mode,photoOutput,videoOutput,warpOutput]);\n  const hasLiveEffect = filter !== \'طبيعي\';\n  const nativeWarpActive = pipelineAvailable && MORPH_FILTERS.has(filter);\n  const cameraComponent = pipelineAvailable ? Camera : FaceCamera;\n  const CameraComponent = cameraComponent;\n\n  useEffect(()=>{\n    setWarpError(false);setWarpReady(false);setWarpFaceVisible(false);setFace(null);\n    if(nativeWarpActive)ca'... 13675 more characters,
    expected: /const nativeWarpActive = wantsNativeWarp && !warpDisabled/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/v3-1-face-warp.test.mjs:128:1
✖ Android native warp pins display rotation and guards torch until CameraX is OPEN (0.588483ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /val targetRotation = textureView\.display\?\.rotation \?: Surface\.ROTATION_0/. Input:
  
  'package com.sawaup.facewarp\n' +
    '\n' +
    'import android.content.Context\n' +
    'import android.graphics.Bitmap\n' +
    'import android.graphics.SurfaceTexture\n' +
    'import android.view.TextureView\n' +
    'import android.view.ViewGroup\n' +
    'import android.os.Handler\n' +
    'import android.os.Looper\n' +
    'import androidx.core.content.ContextCompat\n' +
    'import androidx.lifecycle.DefaultLifecycleObserver\n' +
    'import androidx.lifecycle.LifecycleOwner\n' +
    'import java.lang.ref.WeakReference\n' +
    'import java.util.concurrent.ConcurrentHashMap\n' +
    'import com.google.mlkit.vision.common.InputImage\n' +
    'import com.google.mlkit.vision.face.Face\n' +
    'import com.google.mlkit.vision.face.FaceDetection\n' +
    'import com.google.mlkit.vision.face.FaceDetector\n' +
    'import com.google.mlkit.vision.face.FaceDetectorOptions\n' +
    'import com.google.mlkit.vision.face.FaceContour\n' +
    'import com.google.mlkit.vision.facemesh.FaceMesh\n' +
    'import com.google.mlkit.vision.facemesh.FaceMeshDetection\n' +
    'import com.google.mlkit.vision.facemesh.FaceMeshDetector\n' +
    'import com.google.mlkit.vision.facemesh.FaceMeshDetectorOptions\n' +
    'import expo.modules.kotlin.AppContext\n' +
    'import expo.modules.kotlin.viewevent.EventDispatcher\n' +
    'import expo.modules.kotlin.views.ExpoView\n' +
    'import java.util.concurrent.atomic.AtomicBoolean\n' +
    'import kotlin.math.PI\n' +
    'import kotlin.math.abs\n' +
    'import kotlin.math.atan2\n' +
    'import kotlin.math.max\n' +
    '\n' +
    '\n' +
    '/** Processing/rendering only. VisionCamera is the sole owner of the Camera device. */\n' +
    'class SawaFaceWarpView(context: Context, appContext: AppContext) :\n' +
    '  ExpoView(context,appContext), TextureView.SurfaceTextureListener, DefaultLifecycleObserver {\n' +
    '  val onReady by EventDispatcher()\n' +
    '  val onError by EventDispatcher()\n' +
    '  val onFaceState by EventDispatcher()\n' +
    '  private val main=Handler(Looper.getMainLooper())\n' +
    '  private val mainExecutor=ContextCompat.getMainExecutor(context)\n' +
    '  private val textureView=TextureView(context).also {\n' +
    '    it.isOpaque=false; it.alpha=0f; it.surfaceTextureListener=this\n' +
    '    addView(it,ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT))\n' +
    '  }\n' +
    '  @Volatile private var active=false\n' +
    '  @Volatile private var foreground=true\n' +
    '  @Volatile private var attached=false\n' +
    '  @Volatile private var filter="طبيعي"\n' +
    '  @Volatile private var facing="front"\n' +
    '  @Volatile private var generation=0\n' +
    '  @Volatile private var pixelWidth=1\n' +
    '  @Volatile private var pixelHeight=1\n' +
    '  private var sessionId=""\n' +
    '  private var ready=false\n' +
    '  private var renderer:WarpRenderer?=null\n' +
    '  private var rendererStopping=false\n' +
    '  private var meshDetector:FaceMeshDetector?=null\n' +
    '  private var fallbackDetector:FaceDetector?=null\n' +
    '  private val busy=AtomicBoolean(false)\n' +
    '  private var detectorInFlight=false\n' +
    '  private var owner:LifecycleOwner?=null\n' +
    '  private var errorReported=false\n' +
    '  private var renderFailed=false\n' +
    '\n' +
    '  internal fun wantsFrames()=active && foreground && attached && filter!="طبيعي" && !renderFailed\n' +
    '  internal fun reserveFrame():Int? = if(wantsFrames() && busy.compareAndSet(false,true))generation else null\n' +
    '  internal fun abandonFrame(){busy.set(false)}\n' +
    '  internal fun acceptFrame(bitmap:Bitmap,rotation:Int,crop:IntArray,token:Int){\n' +
    '    main.post {\n' +
    '      if(token!=generation || !wantsFrames()){bitmap.recycle();busy.set(false);return@post}\n' +
    '      val geometry=FrameGeometry.create(bitmap.width,bitmap.height,rotation,facing=="front",pixelWidth,pixelHeight,crop[0],crop[1],crop[2],crop[3])\n' +
    '      val image=InputImage.fromBitmap(bitmap,rotation)\n' +
    '      detectorInFlight=true\n' +
    '      fun finish(state:WarpFaceState) {\n' +
    '        detectorInFlight=false\n' +
    '        if(token!=generation || !wantsFrames()) {\n' +
    '          bitmap.recycle();busy.set(false);closeDetectorsIfIdle();return\n' +
    '        }\n' +
    '        emitFace(state,geometry)\n' +
    '        if(MORPH_FILTERS.contains(filter)) {\n' +
    '          val gl=ensureRenderer()\n' +
    '          if(gl==null){bitmap.recycle();busy.set(false);return}\n' +
    '          gl.draw(bitmap,geometry,state,filter){rendered->main.post{\n' +
    '            busy.set(false)\n' +
    '            if(rendered && token==generation && wantsFrames() && !ready){\n' +
    '              ready=true;textureView.alpha=1f\n' +
    '              onReady(mapOf("engine" to "vision-camera-frames","filter" to filter))\n' +
    '            }\n' +
    '          }}\n' +
    '        } else {bitmap.recycle();busy.set(false)}\n' +
    '      }\n' +
    '      fun fallback(){\n' +
    '        if(token!=generation || !wantsFrames()){finish(WarpFaceState());return}\n' +
    '        try {\n' +
    '          val detector=fallbackDetector ?: FaceDetection.getClient(FaceDetectorOptions.Builder()\n' +
    '            .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)\n' +
    '            .setContourMode(FaceDetectorOptions.CONTOUR_MODE_ALL).setMinFaceSize(.12f).build()).also{fallbackDetector=it}\n' +
    '          detector.process(image).addOnSuccessListener(mainExecutor){faces->\n' +
    '            val face=chooseFace(faces)\n' +
    '            val state=if(face!=null && isPoseSafe(face))fallbackFaceState(face,bitmap.width,bitmap.height,rotation) else WarpFaceState()\n' +
    '            finish(state)\n' +
    '          }.addOnFailureListener(mainExecutor){finish(WarpFaceState())}\n' +
    '        } catch(e:Exception){finish(WarpFaceState());report(e)}\n' +
    '      }\n' +
    '      if(!MORPH_FILTERS.contains(filter)){fallback();return@post}\n' +
    '      try {\n' +
    '        val detector=meshDetector ?: FaceMeshDetection.getClient(FaceMeshDetectorOptions.Builder().build()).also{meshDetector=it}\n' +
    '        detector.process(image).addOnSuccessListener(mainExecutor){meshes->\n' +
    '          if(token!=generation || !wantsFrames()){finish(WarpFaceState());return@addOnSuccessListener}\n' +
    '          val mesh=chooseMesh(meshes)\n' +
    '          if(mesh==null || !isMeshPoseSafe(mesh))fallback()\n' +
    '          else finish(meshFaceState(mesh,bitmap.width,bitmap.height,rotation))\n' +
    '        }.addOnFailureListener(mainExecutor){fallback()}\n' +
    '      } catch(e:Exception){fallback();report(e)}\n' +
    '    }\n' +
    '  }\n' +
    '  private fun emitFace(state:WarpFaceState,g:FrameGeometry){\n' +
    '    val a=g.uprightToDisplay(state.faceCenterX-state.faceRadiusX,state.faceCenterY-state.faceRadiusY)\n' +
    '    val b=g.uprightToDisplay(state.faceCenterX+state.faceRadiusX,state.faceCenterY+state.faceRadiusY)\n' +
    '    val density=resources.displayMetrics.density\n' +
    '    val bounds=mapOf("x" to minOf(a.first,b.first)*pixelWidth/density,"y" to minOf(a.second,b.second)*pixelHeight/density,\n' +
    '      "width" to abs(b.first-a.first)*pixelWidth/density,"height" to abs(b.second-a.second)*pixelHeight/density)\n' +
    '    onFaceState(mapOf("visible" to state.hasFace,"filter" to filter,"bounds" to bounds))\n' +
    '  }\n' +
    '  fun setSessionId(value:String){\n' +
    '    if(sessionId==value)return\n' +
    '    Registry.remove(sessionId,this);sessionId=value\n' +
    '    if(attached)Registry.put(value,this)\n' +
    '  }\n' +
    '  fun setFacing(value:String){if(facing!=value){facing=value;invalidateFrames()}}\n' +
    '  fun setFilter(value:String){if(filter!=value){filter=value;renderFailed=false;errorReported=false;invalidateFrames()}}\n' +
    '  fun setActive(value:Boolean){if(active!=value){active=value;invalidateFrames();if(!value)closeDetectorsIfIdle()}}\n' +
    '  private fun invalidateFrames(){\n' +
    '    ++generation;ready=false;textureView.alpha=0f\n' +
    '    // Leave the output connected. Filter changes do not create or stop a camera.\n' +
    '    if(filter=="طبيعي" || !active || !foreground) {\n' +
    '      closeDetectorsIfIdle()\n' +
    '      stopRenderer()\n' +
    '    }\n' +
    '  }\n' +
    '  private fun stopRenderer(){\n' +
    '    val gl=renderer ?: return\n' +
    '    if(rendererStopping)return\n' +
    '    rendererStopping=true\n' +
    '    gl.release{main.post{if(renderer===gl){renderer=null;rendererStopping=false}}}\n' +
    '  }\n' +
    '  private fun closeDetectorsIfIdle(){\n' +
    '    if(detectorInFlight)return\n' +
    '    meshDetector?.close();meshDetector=null\n' +
    '    fallbackDetector?.close();fallbackDetector=null\n' +
    '  }\n' +
    '  private fun ensureRenderer():WarpRenderer?{\n' +
    '    if(renderFailed || rendererStopping || !textureView.isAvailable)return null\n' +
    '    return renderer ?: WarpRenderer(textureView.surfaceTexture ?: return null){error->main.post{\n' +
    '      renderFailed=true;textureView.alpha=0f;ready=false;stopRenderer();report(error)\n' +
    '    }}.also{renderer=it}\n' +
    '  }\n' +
    '  private fun report(error:Throwable){\n' +
    '    if(!attached || errorReported)return\n' +
    '    errorReported=true\n' +
    '    android.util.Log.e("SawaFaceWarp","Frame processing failed",error)\n' +
    '    onError(mapOf("message" to "تعذر معالجة الفلتر. أعد اختيار الفلتر للمحاولة.","engine" to "vision-camera-frames"))\n' +
    '  }\n' +
    '  override fun onLayout(changed:Boolean,left:Int,top:Int,right:Int,bottom:Int){\n' +
    '    pixelWidth=max(1,right-left);pixelHeight=max(1,bottom-top)\n' +
    '    textureView.layout(0,0,pixelWidth,pixelHeight)\n' +
    '  }\n' +
    '  override fun onSurfaceTextureAvailable(surface:SurfaceTexture,width:Int,height:Int){}\n' +
    '  override fun onSurfaceTextureSizeChanged(surface:SurfaceTexture,width:Int,height:Int){invalidateFrames()}\n' +
    '  override fun onSurfaceTextureUpdated(surface:SurfaceTexture){}\n' +
    '  override fun onSurfaceTextureDestroyed(surface:SurfaceTexture):Boolean{\n' +
    '    ++generation;ready=false\n' +
    '    val gl=renderer;renderer=null;rendererStopping=false\n' +
    '    if(gl!=null)gl.release{surface.release()} else surface.release()\n' +
    '    return false // TextureView must NOT destroy a surface still used by the GL thread.\n' +
    '  }\n' +
    '  override fun onAttachedToWindow(){\n' +
    '    super.onAttachedToWindow();attached=true;Registry.put(sessionId,this)\n' +
    '    owner=appContext.currentActivity as? LifecycleOwner\n' +
    '    owner?.lifecycle?.addObserver(this)\n' +
    '  }\n' +
    '  override fun onDetachedFromWindow(){\n' +
    '    attached=false;Registry.remove(sessionId,this);++generation\n' +
    '    owner?.lifecycle?.removeObserver(this);owner=null\n' +
    '    closeDetectorsIfIdle()\n' +
    '    // Texture destruction callback owns EGL teardown and SurfaceTexture release.\n' +
    '    super.onDetachedFromWindow()\n' +
    '  }\n' +
    '  override fun onStop(owner:LifecycleOwner){foreground=false;invalidateFrames()}\n' +
    '  override fun onStart(owner:LifecycleOwner){foreground=true;invalidateFrames()}\n' +
    '\n' +
    '  private fun chooseMesh(meshes: List<FaceMesh>): FaceMesh? = meshes.maxByOrNull {\n' +
    '    it.boundingBox.width() * it.boundingBox.height()\n' +
    '  }\n' +
    '\n' +
    '  private fun chooseFace(faces: List<Face>): Face? = faces.maxByOrNull { it.boundingBox.width() * it.boundingBox.height() }\n' +
    '\n' +
    '  private fun isPoseSafe(face: Face): Boolean {\n' +
    '    // Extreme head poses make 2D warps look unstable. Disable deformation for\n' +
    '    // that frame and keep the untouched camera image instead.\n' +
    '    return abs(face.headEulerAngl'... 8866 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/v3-1-face-warp.test.mjs:129:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async Test.processPendingSubtests (node:internal/test_runner/test:960:7) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: 'package com.sawaup.facewarp\n\nimport android.content.Context\nimport android.graphics.Bitmap\nimport android.graphics.SurfaceTexture\nimport android.view.TextureView\nimport android.view.ViewGroup\nimport android.os.Handler\nimport android.os.Looper\nimport androidx.core.content.ContextCompat\nimport androidx.lifecycle.DefaultLifecycleObserver\nimport androidx.lifecycle.LifecycleOwner\nimport java.lang.ref.WeakReference\nimport java.util.concurrent.ConcurrentHashMap\nimport com.google.mlkit.vision.common.InputImage\nimport com.google.mlkit.vision.face.Face\nimport com.google.mlkit.vision.face.FaceDetection\nimport com.google.mlkit.vision.face.FaceDetector\nimport com.google.mlkit.vision.face.FaceDetectorOptions\nimport com.google.mlkit.vision.face.FaceContour\nimport com.google.mlkit.vision.facemesh.FaceMesh\nimport com.google.mlkit.vision.facemesh.FaceMeshDetection\nimport com.google.mlkit.vision.facemesh.FaceMeshDetector\nimport com.google.mlkit.vision.facemesh.FaceMeshDetectorOptions\nimport expo.modules.kotlin.AppContext\nimport expo.modules.kotlin.viewevent.EventDispatcher\nimport expo.modules.kotlin.views.ExpoView\nimport java.util.concurrent.atomic.AtomicBoolean\nimport kotlin.math.PI\nimport kotlin.math.abs\nimport kotlin.math.atan2\nimport kotlin.math.max\n\n\n/** Processing/rendering only. VisionCamera is the sole owner of the Camera device. */\nclass SawaFaceWarpView(context: Context, appContext: AppContext) :\n  ExpoView(context,appContext), TextureView.SurfaceTextureListener, DefaultLifecycleObserver {\n  val onReady by EventDispatcher()\n  val onError by EventDispatcher()\n  val onFaceState by EventDispatcher()\n  private val main=Handler(Looper.getMainLooper())\n  private val mainExecutor=ContextCompat.getMainExecutor(context)\n  private val textureView=TextureView(context).also {\n    it.isOpaque=false; it.alpha=0f; it.surfaceTextureListener=this\n    addView(it,ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.MATCH_PARENT))\n  }\n  @Volatile private var active=false\n  @Volatile private var foreground=true\n  @Volatile private var attached=false\n  @Volatile private var filter="طبيعي"\n  @Volatile private var facing="front"\n  @Volatile private var generation=0\n  @Volatile private var pixelWidth=1\n  @Volatile private var pixelHeight=1\n  private var sessionId=""\n  private var ready=false\n  private var renderer:WarpRenderer?=null\n  private var rendererStopping=false\n  private var meshDetector:FaceMeshDetector?=null\n  private var fallbackDetector:FaceDetector?=null\n  private val busy=AtomicBoolean(false)\n  private var detectorInFlight=false\n  private var owner:LifecycleOwner?=null\n  private var errorReported=false\n  private var renderFailed=false\n\n  internal fun wantsFrames()=active && foreground && attached && filter!="طبيعي" && !renderFailed\n  internal fun reserveFrame():Int? = if(wantsFrames() && busy.compareAndSet(false,true))generation else null\n  internal fun abandonFrame(){busy.set(false)}\n  internal fun acceptFrame(bitmap:Bitmap,rotation:Int,crop:IntArray,token:Int){\n    main.post {\n      if(token!=generation || !wantsFrames()){bitmap.recycle();busy.set(false);return@post}\n      val geometry=FrameGeometry.create(bitmap.width,bitmap.height,rotation,facing=="front",pixelWidth,pixelHeight,crop[0],crop[1],crop[2],crop[3])\n      val image=InputImage.fromBitmap(bitmap,rotation)\n      detectorInFlight=true\n      fun finish(state:WarpFaceState) {\n        detectorInFlight=false\n        if(token!=generation || !wantsFrames()) {\n          bitmap.recycle();busy.set(false);closeDetectorsIfIdle();return\n        }\n        emitFace(state,geometry)\n        if(MORPH_FILTERS.contains(filter)) {\n          val gl=ensureRenderer()\n          if(gl==null){bitmap.recycle();busy.set(false);return}\n          gl.draw(bitmap,geometry,state,filter){rendered->main.post{\n            busy.set(false)\n            if(rendered && token==generation && wantsFrames() && !ready){\n              ready=true;textureView.alpha=1f\n              onReady(mapOf("engine" to "vision-camera-frames","filter" to filter))\n            }\n          }}\n        } else {bitmap.recycle();busy.set(false)}\n      }\n      fun fallback(){\n        if(token!=generation || !wantsFrames()){finish(WarpFaceState());return}\n        try {\n          val detector=fallbackDetector ?: FaceDetection.getClient(FaceDetectorOptions.Builder()\n            .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)\n            .setContourMode(FaceDetectorOptions.CONTOUR_MODE_ALL).setMinFaceSize(.12f).build()).also{fallbackDetector=it}\n          detector.process(image).addOnSuccessListener(mainExecutor){faces->\n            val face=chooseFace(faces)\n            val state=if(face!=null && isPoseSafe(face))fallbackFaceState(face,bitmap.width,bitmap.height,rotation) else WarpFaceState()\n            finish(state)\n          }.addOnFailureListener(mainExecutor){finish(WarpFaceState())}\n        } catch(e:Exception){finish(WarpFaceState());report(e)}\n      }\n      if(!MORPH_FILTERS.contains(filter)){fallback();return@post}\n      try {\n        val detector=meshDetector ?: FaceMeshDetection.getClient(FaceMeshDetectorOptions.Builder().build()).also{meshDetector=it}\n        detector.process(image).addOnSuccessListener(mainExecutor){meshes->\n          if(token!=generation || !wantsFrames()){finish(WarpFaceState());return@addOnSuccessListener}\n          val mesh=chooseMesh(meshes)\n          if(mesh==null || !isMeshPoseSafe(mesh))fallback()\n          else finish(meshFaceState(mesh,bitmap.width,bitmap.height,rotation))\n        }.addOnFailureListener(mainExecutor){fallback()}\n      } catch(e:Exception){fallback();report(e)}\n    }\n  }\n  private fun emitFace(state:WarpFaceState,g:FrameGeometry){\n    val a=g.uprightToDisplay(state.faceCenterX-state.faceRadiusX,state.faceCenterY-state.faceRadiusY)\n    val b=g.uprightToDisplay(state.faceCenterX+state.faceRadiusX,state.faceCenterY+state.faceRadiusY)\n    val density=resources.displayMetrics.density\n    val bounds=mapOf("x" to minOf(a.first,b.first)*pixelWidth/density,"y" to minOf(a.second,b.second)*pixelHeight/density,\n      "width" to abs(b.first-a.first)*pixelWidth/density,"height" to abs(b.second-a.second)*pixelHeight/density)\n    onFaceState(mapOf("visible" to state.hasFace,"filter" to filter,"bounds" to bounds))\n  }\n  fun setSessionId(value:String){\n    if(sessionId==value)return\n    Registry.remove(sessionId,this);sessionId=value\n    if(attached)Registry.put(value,this)\n  }\n  fun setFacing(value:String){if(facing!=value){facing=value;invalidateFrames()}}\n  fun setFilter(value:String){if(filter!=value){filter=value;renderFailed=false;errorReported=false;invalidateFrames()}}\n  fun setActive(value:Boolean){if(active!=value){active=value;invalidateFrames();if(!value)closeDetectorsIfIdle()}}\n  private fun invalidateFrames(){\n    ++generation;ready=false;textureView.alpha=0f\n    // Leave the output connected. Filter changes do not create or stop a camera.\n    if(filter=="طبيعي" || !active || !foreground) {\n      closeDetectorsIfIdle()\n      stopRenderer()\n    }\n  }\n  private fun stopRenderer(){\n    val gl=renderer ?: return\n    if(rendererStopping)return\n    rendererStopping=true\n    gl.release{main.post{if(renderer===gl){renderer=null;rendererStopping=false}}}\n  }\n  private fun closeDetectorsIfIdle(){\n    if(detectorInFlight)return\n    meshDetector?.close();meshDetector=null\n    fallbackDetector?.close();fallbackDetector=null\n  }\n  private fun ensureRenderer():WarpRenderer?{\n    if(renderFailed || rendererStopping || !textureView.isAvailable)return null\n    return renderer ?: WarpRenderer(textureView.surfaceTexture ?: return null){error->main.post{\n      renderFailed=true;textureView.alpha=0f;ready=false;stopRenderer();report(error)\n    }}.also{renderer=it}\n  }\n  private fun report(error:Throwable){\n    if(!attached || errorReported)return\n    errorReported=true\n    android.util.Log.e("SawaFaceWarp","Frame processing failed",error)\n    onError(mapOf("message" to "تعذر معالجة الفلتر. أعد اختيار الفلتر للمحاولة.","engine" to "vision-camera-frames"))\n  }\n  override fun onLayout(changed:Boolean,left:Int,top:Int,right:Int,bottom:Int){\n    pixelWidth=max(1,right-left);pixelHeight=max(1,bottom-top)\n    textureView.layout(0,0,pixelWidth,pixelHeight)\n  }\n  override fun onSurfaceTextureAvailable(surface:SurfaceTexture,width:Int,height:Int){}\n  override fun onSurfaceTextureSizeChanged(surface:SurfaceTexture,width:Int,height:Int){invalidateFrames()}\n  override fun onSurfaceTextureUpdated(surface:SurfaceTexture){}\n  override fun onSurfaceTextureDestroyed(surface:SurfaceTexture):Boolean{\n    ++generation;ready=false\n    val gl=renderer;renderer=null;rendererStopping=false\n    if(gl!=null)gl.release{surface.release()} else surface.release()\n    return false // TextureView must NOT destroy a surface still used by the GL thread.\n  }\n  override fun onAttachedToWindow(){\n    super.onAttachedToWindow();attached=true;Registry.put(sessionId,this)\n    owner=appContext.currentActivity as? LifecycleOwner\n    owner?.lifecycle?.addObserver(this)\n  }\n  override fun onDetachedFromWindow(){\n    attached=false;Registry.remove(sessionId,this);++generation\n    owner?.lifecycle?.removeObserver(this);owner=null\n    closeDetectorsIfIdle()\n    // Texture destruction callback owns EGL teardown and SurfaceTexture release.\n    super.onDetachedFromWindow()\n  }\n  override fun onStop(owner:LifecycleOwner){foreground=false;invalidateFrames()}\n  override fun onStart(owner:LifecycleOwner){foreground=true;invalidateFrames()}\n\n  private fun chooseMesh(meshes: List<FaceMesh>): FaceMesh? = meshes.maxByOrNull {\n    it.boundingBox.width() * it.boundingBox.height()\n  }\n\n  private fun chooseFace(faces: List<Face>): Face? = faces.maxByOrNull { it.boundingBox.width() * it.boundingBox.height() }\n\n  private fun isPoseSafe(face: Face): Boolean {\n    // Extreme head poses make 2D warps look unstable. Disable deformation for\n    // that frame and keep the untouched camera image instead.\n    return abs(face.headEulerAngl'... 8866 more characters,
    expected: /val targetRotation = textureView\.display\?\.rotation \?: Surface\.ROTATION_0/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/v3-1-regressions.test.mjs:53:1
✖ 3.1 call path hardens permissions, startup timeouts, actual connection state and self view (1.351311ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /ensureCallPermissions/. Input:
  
  "import React, { useEffect, useRef, useState } from 'react';\n" +
    "import { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\n" +
    "import { SafeAreaView } from 'react-native-safe-area-context';\n" +
    "import { Ionicons } from '@expo/vector-icons';\n" +
    "import { setAudioModeAsync } from 'expo-audio';\n" +
    'import {\n' +
    '  RTCPeerConnection,\n' +
    '  RTCIceCandidate,\n' +
    '  RTCSessionDescription,\n' +
    '  RTCView,\n' +
    '  mediaDevices,\n' +
    "} from 'react-native-webrtc';\n" +
    "import { ICE_SERVERS, fallbackName, nowIso } from './core';\n" +
    "import { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n" +
    '\n' +
    'const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n' +
    '\n' +
    'function IconButton({ icon, onPress, active, danger, disabled }) {\n' +
    '  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n' +
    '    <Ionicons name={icon} size={26} color="#fff" />\n' +
    '  </Pressable>;\n' +
    '}\n' +
    '\n' +
    "export default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n" +
    '  const [callRow, setCallRow] = useState(incomingCall || null);\n' +
    "  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n" +
    "  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '  const [localStream, setLocalStream] = useState(null);\n' +
    '  const [remoteStream, setRemoteStream] = useState(null);\n' +
    '  const [muted, setMuted] = useState(false);\n' +
    '  const [speakerOn, setSpeakerOn] = useState(false);\n' +
    '  const [cameraOff, setCameraOff] = useState(true);\n' +
    '  const [seconds, setSeconds] = useState(0);\n' +
    '  const pcRef = useRef(null);\n' +
    '  const localRef = useRef(null);\n' +
    '  const callRef = useRef(incomingCall || null);\n' +
    '  const seenCandidates = useRef(new Set());\n' +
    '  const closing = useRef(false);\n' +
    '  const acceptedAt = useRef(null);\n' +
    '\n' +
    '  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n' +
    '  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n' +
    '  // Unified call: preserve the proven v3.0 video-call transport for every call.\n' +
    '  const isVideo = true;\n' +
    '\n' +
    '  const closeMedia = () => {\n' +
    '    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n' +
    '    try { pcRef.current?.close?.(); } catch {}\n' +
    '    pcRef.current = null;\n' +
    '    localRef.current = null;\n' +
    '    setLocalStream(null);\n' +
    '    setRemoteStream(null);\n' +
    '    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n' +
    '  };\n' +
    '\n' +
    "  const finish = async (status = 'ended') => {\n" +
    '    if (closing.current) return;\n' +
    '    closing.current = true;\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    '      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n' +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closeMedia();\n' +
    '    onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const preparePeer = async currentCall => {\n' +
    '    if (pcRef.current) return pcRef.current;\n' +
    "    setStatusText('جارٍ تجهيز الميكروفون...');\n" +
    '    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n' +
    '    setSpeakerOn(false);\n' +
    '    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n' +
    "    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n" +
    '    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n' +
    '    localRef.current = stream;\n' +
    '    setLocalStream(stream);\n' +
    '    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n' +
    '    pcRef.current = pc;\n' +
    '    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n' +
    '    pc.ontrack = event => {\n' +
    '      const stream0 = event.streams?.[0];\n' +
    '      if (stream0) setRemoteStream(stream0);\n' +
    '    };\n' +
    '    pc.onicecandidate = event => {\n' +
    '      if (event.candidate && currentCall?.id) {\n' +
    '        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n' +
    '      }\n' +
    '    };\n' +
    '    pc.onconnectionstatechange = () => {\n' +
    '      const state = pc.connectionState;\n' +
    "      if (state === 'connected') {\n" +
    "        setPhase('connected');\n" +
    "        setStatusText('متصل');\n" +
    '        acceptedAt.current ||= Date.now();\n' +
    "      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n" +
    "      else if (state === 'failed') {\n" +
    "        setStatusText('تعذر الاتصال');\n" +
    "        finish('failed').catch(() => {});\n" +
    '      }\n' +
    '    };\n' +
    '    return pc;\n' +
    '  };\n' +
    '\n' +
    '  const outgoing = async () => {\n' +
    '    try {\n' +
    "      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n" +
    "      const row = await startCall(session, peerPhone, 'video');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      const pc = await preparePeer(row);\n' +
    "      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n" +
    '      const offer = await pc.createOffer();\n' +
    '      await pc.setLocalDescription(offer);\n' +
    '      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر بدء المكالمة');\n" +
    "      setTimeout(() => finish('failed'), 1300);\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  const reject = async () => {\n' +
    '    const row = callRef.current;\n' +
    '    if (row?.id) {\n' +
    "      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n" +
    '      await clearCallCandidates(row.id).catch(() => {});\n' +
    '    }\n' +
    '    closing.current = true; closeMedia(); onClose?.();\n' +
    '  };\n' +
    '\n' +
    '  const accept = async () => {\n' +
    '    try {\n' +
    '      let row = callRef.current;\n' +
    "      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n" +
    "      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n" +
    '      const pc = await preparePeer(row);\n' +
    '      for (let i = 0; i < 20 && !row.offer; i++) {\n' +
    '        await sleep(350);\n' +
    '        row = await getCall(row.id);\n' +
    '      }\n' +
    "      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n" +
    '      callRef.current = row; setCallRow(row);\n' +
    '      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n' +
    '      const answer = await pc.createAnswer();\n' +
    '      await pc.setLocalDescription(answer);\n' +
    "      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n" +
    "      callRef.current = saved || { ...row, status: 'accepted', answer };\n" +
    '      setCallRow(callRef.current);\n' +
    '      acceptedAt.current = Date.now();\n' +
    '    } catch (error) {\n' +
    "      setStatusText(error?.message || 'تعذر قبول المكالمة');\n" +
    "      await finish('failed');\n" +
    '    }\n' +
    '  };\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    closing.current = false;\n' +
    '    seenCandidates.current = new Set();\n' +
    '    acceptedAt.current = null;\n' +
    '    callRef.current = incomingCall || null;\n' +
    '    setCallRow(incomingCall || null);\n' +
    "    setPhase(incomingCall ? 'incoming' : 'starting');\n" +
    "    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n" +
    '    setSeconds(0);\n' +
    '    setMuted(false);\n' +
    '    setCameraOff(true);\n' +
    '    setSpeakerOn(false);\n' +
    '    if (!incomingCall) outgoing();\n' +
    '    return () => { closeMedia(); };\n' +
    '    // one call lifecycle per mount/visible session\n' +
    '    // eslint-disable-next-line react-hooks/exhaustive-deps\n' +
    '  }, [visible, incomingCall?.id]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible || !callRow?.id) return;\n' +
    '    let alive = true;\n' +
    '    const poll = async () => {\n' +
    '      try {\n' +
    '        const row = await getCall(callRow.id);\n' +
    '        if (!alive || !row) return;\n' +
    '        callRef.current = row; setCallRow(row);\n' +
    "        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n" +
    "          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n" +
    '          closeMedia();\n' +
    '          setTimeout(() => { if (alive) onClose?.(); }, 650);\n' +
    '          return;\n' +
    '        }\n' +
    '        const pc = pcRef.current;\n' +
    '        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n' +
    '          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n' +
    '          acceptedAt.current ||= Date.now();\n' +
    "          setPhase('connected'); setStatusText('متصل');\n" +
    '        }\n' +
    '        if (pc) {\n' +
    '          const candidates = await getCallCandidates(row.id);\n' +
    '          for (const c of candidates || []) {\n' +
    '            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n' +
    '            // ICE candidates must wait until the remote description exists; otherwise\n' +
    '            // react-native-webrtc can reject them and the candidate would be lost.\n' +
    '            if (!pc.remoteDescription) continue;\n' +
    '            try {\n' +
    '              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n' +
    '              seenCandidates.current.add(c.id);\n' +
    '            } catch {}\n' +
    '          }\n' +
    '        }\n' +
    '      } catch {}\n' +
    '    };\n' +
    '    poll();\n' +
    '    const id = setInterval(poll, 700);\n' +
    '    return () => { alive = false; clearInterval(id); };\n' +
    '  }, [visible, callRow?.id, session.phone]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    '    if (!visible) return;\n' +
    '    const id = setInterval(() => {\n' +
    '      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n' +
    '    }, 1000);\n' +
    '    return () => clearInterval(id);\n' +
    '  }, [visible]);\n' +
    '\n' +
    '  useEffect(() => {\n' +
    "    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n" +
    '    const id = setTimeout(async () => {\n' +
    '      const row = await getCall(callRow.id).catch(() => null);\n' +
    "      if (row?.status === 'ringing') {\n" +
    "        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n" +
    "        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n" +
    '        await clearCallCandidates(row.id).catch(() => {});\n' +
    "        setStatusText('لم يتم الرد');\n" +
    '        setTimeout(() => onClose?.(), 800);\n' +
    '      }\n' +
    '    }, 45000);\n' +
    '    return () => clearTimeout(id);\n' +
    '  }, [visible, callRow?.id, incomingCall, phase]);\n' +
    '\n' +
    '  const toggleMute = () => {\n' +
    '    const next = !muted; setMuted(next);\n' +
    '    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n' +
    '  };\n' +
    '  const toggleSpeaker = async () => {\n' +
    '    const next = !s'... 3663 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/v3-1-regressions.test.mjs:54:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async Test.processPendingSubtests (node:internal/test_runner/test:960:7) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: `import React, { useEffect, useRef, useState } from 'react';\nimport { View, Text, Pressable, StyleSheet, ActivityIndicator, Modal } from 'react-native';\nimport { SafeAreaView } from 'react-native-safe-area-context';\nimport { Ionicons } from '@expo/vector-icons';\nimport { setAudioModeAsync } from 'expo-audio';\nimport {\n  RTCPeerConnection,\n  RTCIceCandidate,\n  RTCSessionDescription,\n  RTCView,\n  mediaDevices,\n} from 'react-native-webrtc';\nimport { ICE_SERVERS, fallbackName, nowIso } from './core';\nimport { addCallCandidate, clearCallCandidates, getCall, getCallCandidates, notifyMissedCall, startCall, updateCall } from './services';\n\nconst sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n\nfunction IconButton({ icon, onPress, active, danger, disabled }) {\n  return <Pressable disabled={disabled} onPress={onPress} style={[styles.iconBtn, active && styles.activeBtn, danger && styles.dangerBtn, disabled && { opacity: .45 }]}>\n    <Ionicons name={icon} size={26} color="#fff" />\n  </Pressable>;\n}\n\nexport default function NativeCall({ visible, session, contact, kind = 'audio', incomingCall = null, onClose }) {\n  const [callRow, setCallRow] = useState(incomingCall || null);\n  const [phase, setPhase] = useState(incomingCall ? 'incoming' : 'starting');\n  const [statusText, setStatusText] = useState(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n  const [localStream, setLocalStream] = useState(null);\n  const [remoteStream, setRemoteStream] = useState(null);\n  const [muted, setMuted] = useState(false);\n  const [speakerOn, setSpeakerOn] = useState(false);\n  const [cameraOff, setCameraOff] = useState(true);\n  const [seconds, setSeconds] = useState(0);\n  const pcRef = useRef(null);\n  const localRef = useRef(null);\n  const callRef = useRef(incomingCall || null);\n  const seenCandidates = useRef(new Set());\n  const closing = useRef(false);\n  const acceptedAt = useRef(null);\n\n  const peerPhone = contact?.contact_phone || contact?.phone || (incomingCall ? incomingCall.caller_phone : null);\n  const displayName = contact?.alias || contact?.display_name || contact?.username || fallbackName(peerPhone);\n  // Unified call: preserve the proven v3.0 video-call transport for every call.\n  const isVideo = true;\n\n  const closeMedia = () => {\n    try { localRef.current?.getTracks?.().forEach(t => t.stop()); } catch {}\n    try { pcRef.current?.close?.(); } catch {}\n    pcRef.current = null;\n    localRef.current = null;\n    setLocalStream(null);\n    setRemoteStream(null);\n    setAudioModeAsync({ allowsRecording: false, shouldRouteThroughEarpiece: false }).catch(() => {});\n  };\n\n  const finish = async (status = 'ended') => {\n    if (closing.current) return;\n    closing.current = true;\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status, ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closeMedia();\n    onClose?.();\n  };\n\n  const preparePeer = async currentCall => {\n    if (pcRef.current) return pcRef.current;\n    setStatusText('جارٍ تجهيز الميكروفون...');\n    await setAudioModeAsync({ playsInSilentMode: true, allowsRecording: true, shouldRouteThroughEarpiece: true });\n    setSpeakerOn(false);\n    // Keep the exact v3.0 video-call negotiation path, but start with the camera track disabled.\n    const stream = await mediaDevices.getUserMedia({ audio: true, video: { facingMode: 'user' } });\n    stream.getVideoTracks().forEach(track => { track.enabled = false; });\n    localRef.current = stream;\n    setLocalStream(stream);\n    const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS });\n    pcRef.current = pc;\n    stream.getTracks().forEach(track => pc.addTrack(track, stream));\n    pc.ontrack = event => {\n      const stream0 = event.streams?.[0];\n      if (stream0) setRemoteStream(stream0);\n    };\n    pc.onicecandidate = event => {\n      if (event.candidate && currentCall?.id) {\n        addCallCandidate(currentCall.id, session.phone, event.candidate.toJSON?.() || event.candidate).catch(() => {});\n      }\n    };\n    pc.onconnectionstatechange = () => {\n      const state = pc.connectionState;\n      if (state === 'connected') {\n        setPhase('connected');\n        setStatusText('متصل');\n        acceptedAt.current ||= Date.now();\n      } else if (state === 'connecting') setStatusText('جارٍ الاتصال...');\n      else if (state === 'failed') {\n        setStatusText('تعذر الاتصال');\n        finish('failed').catch(() => {});\n      }\n    };\n    return pc;\n  };\n\n  const outgoing = async () => {\n    try {\n      if (!peerPhone) throw new Error('تعذر تحديد المستخدم.');\n      const row = await startCall(session, peerPhone, 'video');\n      callRef.current = row; setCallRow(row);\n      const pc = await preparePeer(row);\n      setPhase('ringing'); setStatusText('جارٍ الاتصال...');\n      const offer = await pc.createOffer();\n      await pc.setLocalDescription(offer);\n      await updateCall(row.id, { offer: { type: offer.type, sdp: offer.sdp } });\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر بدء المكالمة');\n      setTimeout(() => finish('failed'), 1300);\n    }\n  };\n\n  const reject = async () => {\n    const row = callRef.current;\n    if (row?.id) {\n      await updateCall(row.id, { status: 'rejected', ended_at: nowIso() }).catch(() => {});\n      await clearCallCandidates(row.id).catch(() => {});\n    }\n    closing.current = true; closeMedia(); onClose?.();\n  };\n\n  const accept = async () => {\n    try {\n      let row = callRef.current;\n      if (!row?.id) throw new Error('المكالمة غير متاحة.');\n      setPhase('connecting'); setStatusText('جارٍ الاتصال...');\n      const pc = await preparePeer(row);\n      for (let i = 0; i < 20 && !row.offer; i++) {\n        await sleep(350);\n        row = await getCall(row.id);\n      }\n      if (!row?.offer) throw new Error('لم يصل طلب الاتصال.');\n      callRef.current = row; setCallRow(row);\n      await pc.setRemoteDescription(new RTCSessionDescription(row.offer));\n      const answer = await pc.createAnswer();\n      await pc.setLocalDescription(answer);\n      const saved = await updateCall(row.id, { status: 'accepted', answer: { type: answer.type, sdp: answer.sdp }, accepted_at: nowIso() });\n      callRef.current = saved || { ...row, status: 'accepted', answer };\n      setCallRow(callRef.current);\n      acceptedAt.current = Date.now();\n    } catch (error) {\n      setStatusText(error?.message || 'تعذر قبول المكالمة');\n      await finish('failed');\n    }\n  };\n\n  useEffect(() => {\n    if (!visible) return;\n    closing.current = false;\n    seenCandidates.current = new Set();\n    acceptedAt.current = null;\n    callRef.current = incomingCall || null;\n    setCallRow(incomingCall || null);\n    setPhase(incomingCall ? 'incoming' : 'starting');\n    setStatusText(incomingCall ? 'مكالمة واردة' : 'جارٍ بدء المكالمة...');\n    setSeconds(0);\n    setMuted(false);\n    setCameraOff(true);\n    setSpeakerOn(false);\n    if (!incomingCall) outgoing();\n    return () => { closeMedia(); };\n    // one call lifecycle per mount/visible session\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [visible, incomingCall?.id]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id) return;\n    let alive = true;\n    const poll = async () => {\n      try {\n        const row = await getCall(callRow.id);\n        if (!alive || !row) return;\n        callRef.current = row; setCallRow(row);\n        if (['rejected', 'ended', 'failed', 'missed'].includes(row.status)) {\n          setStatusText(row.status === 'rejected' ? 'تم رفض المكالمة' : row.status === 'missed' ? 'لم يتم الرد' : 'انتهت المكالمة');\n          closeMedia();\n          setTimeout(() => { if (alive) onClose?.(); }, 650);\n          return;\n        }\n        const pc = pcRef.current;\n        if (pc && row.answer && !pc.remoteDescription && row.caller_phone === session.phone) {\n          await pc.setRemoteDescription(new RTCSessionDescription(row.answer));\n          acceptedAt.current ||= Date.now();\n          setPhase('connected'); setStatusText('متصل');\n        }\n        if (pc) {\n          const candidates = await getCallCandidates(row.id);\n          for (const c of candidates || []) {\n            if (c.from_phone === session.phone || seenCandidates.current.has(c.id)) continue;\n            // ICE candidates must wait until the remote description exists; otherwise\n            // react-native-webrtc can reject them and the candidate would be lost.\n            if (!pc.remoteDescription) continue;\n            try {\n              await pc.addIceCandidate(new RTCIceCandidate(c.candidate));\n              seenCandidates.current.add(c.id);\n            } catch {}\n          }\n        }\n      } catch {}\n    };\n    poll();\n    const id = setInterval(poll, 700);\n    return () => { alive = false; clearInterval(id); };\n  }, [visible, callRow?.id, session.phone]);\n\n  useEffect(() => {\n    if (!visible) return;\n    const id = setInterval(() => {\n      if (acceptedAt.current) setSeconds(Math.max(0, Math.floor((Date.now() - acceptedAt.current) / 1000)));\n    }, 1000);\n    return () => clearInterval(id);\n  }, [visible]);\n\n  useEffect(() => {\n    if (!visible || !callRow?.id || incomingCall || phase === 'connected') return;\n    const id = setTimeout(async () => {\n      const row = await getCall(callRow.id).catch(() => null);\n      if (row?.status === 'ringing') {\n        await updateCall(row.id, { status: 'missed', ended_at: nowIso() }).catch(() => {});\n        await notifyMissedCall(session, peerPhone, 'video', row.id).catch(() => {});\n        await clearCallCandidates(row.id).catch(() => {});\n        setStatusText('لم يتم الرد');\n        setTimeout(() => onClose?.(), 800);\n      }\n    }, 45000);\n    return () => clearTimeout(id);\n  }, [visible, callRow?.id, incomingCall, phase]);\n\n  const toggleMute = () => {\n    const next = !muted; setMuted(next);\n    localRef.current?.getAudioTracks?.().forEach(t => { t.enabled = !next; });\n  };\n  const toggleSpeaker = async () => {\n    const next = !s`... 3663 more characters,
    expected: /ensureCallPermissions/,
    operator: 'match',
    diff: 'simple'
  }

test at tests/v3-native-completion.test.mjs:23:1
✖ Athar live camera is driven by VisionCamera face detection during preview (1.211015ms)
  AssertionError [ERR_ASSERTION]: The input did not match the regular expression /onFacesDetected=/. Input:
  
  "import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';\n" +
    "import { AppState, Platform, StyleSheet, Text, View } from 'react-native';\n" +
    "import { Paths, File } from 'expo-file-system';\n" +
    "import { RecordingPresets, setAudioModeAsync, useAudioRecorder } from 'expo-audio';\n" +
    "import { RecordingView, useViewRecorder } from 'react-native-view-recorder';\n" +
    "import { Camera, useCameraDevice, usePhotoOutput, useVideoOutput } from 'react-native-vision-camera';\n" +
    "import { Camera as FaceCamera } from 'react-native-vision-camera-face-detector';\n" +
    "import SawaFaceWarpView, { createSawaWarpOutput, isFaceWarpNativeAvailable } from '../modules/sawaup-face-warp/src';\n" +
    '\n' +
    "const TEAL = '#2ED2C5';\n" +
    "const PEACH = '#FFB680';\n" +
    '\n' +
    'const LIVE_FILTERS = new Set([\n' +
    "  'صفاء','إشراقة','جمال ناعم','جمال+','عيون','ابتسامة','ضحكة','وجه طفولي','وجه نحيف','وجه ممتلئ',\n" +
    "  'أنف ناعم','شفاه','نمش','وردي','نجوم','مرح','كرتوني','نيون أثر','توقيع Sawa Up',\n" +
    ']);\n' +
    '\n' +
    '// These filters require geometric deformation of camera pixels. On Android they\n' +
    '// use an attached VisionCamera frame output. Filter changes never mount another\n' +
    '// camera owner; the native view only detects/deforms/renders copied frames.\n' +
    'const MORPH_FILTERS = new Set([\n' +
    "  'صفاء','إشراقة','جمال ناعم','جمال+','عيون','ابتسامة','ضحكة','وجه طفولي','وجه نحيف','وجه ممتلئ','أنف ناعم','شفاه',\n" +
    ']);\n' +
    '\n' +
    'function clampBox(bounds) {\n' +
    '  if (!bounds) return null;\n' +
    '  const x = Number(bounds.x ?? bounds.left ?? 0);\n' +
    '  const y = Number(bounds.y ?? bounds.top ?? 0);\n' +
    '  const width = Number(bounds.width ?? 0);\n' +
    '  const height = Number(bounds.height ?? 0);\n' +
    '  if (![x,y,width,height].every(Number.isFinite) || width < 20 || height < 20) return null;\n' +
    '  return { x, y, width, height };\n' +
    '}\n' +
    '\n' +
    'function FaceEffect({ face, filter }) {\n' +
    '  const box = clampBox(face?.bounds);\n' +
    '  if (!box || !LIVE_FILTERS.has(filter)) return null;\n' +
    '  const { x, y, width:w, height:h } = box;\n' +
    "  const faceStyle = { position:'absolute', left:x, top:y, width:w, height:h, borderRadius:Math.max(18,w*.42) };\n" +
    '  const eyeSize = Math.max(12, Math.min(34, w*.16));\n' +
    '  const cheek = Math.max(18, Math.min(40, w*.22));\n' +
    '\n' +
    `  if (filter === 'صفاء') return <View pointerEvents="none" style={[faceStyle, fx.softSkin]} />;\n` +
    `  if (filter === 'إشراقة') return <View pointerEvents="none" style={[faceStyle,fx.faceGlow]}/>;\n` +
    `  if (filter === 'جمال ناعم' || filter === 'جمال+') return <View pointerEvents="none" style={[faceStyle,filter==='جمال+'?fx.beautyPlus:fx.beautySoft]}>\n` +
    '    <View style={[fx.eyeGlow,{left:w*.20,top:h*.32,width:eyeSize,height:eyeSize}]}/><View style={[fx.eyeGlow,{right:w*.20,top:h*.32,width:eyeSize,height:eyeSize}]}/>\n' +
    '    <View style={[fx.lipTint,{left:w*.34,top:h*.67,width:w*.32,height:Math.max(7,h*.055)}]}/>\n' +
    '  </View>;\n' +
    `  if (filter === 'عيون') return <View pointerEvents="none" style={faceStyle}><View style={[fx.eyeRing,{left:w*.16,top:h*.28,width:eyeSize*1.32,height:eyeSize*1.05}]}/><View style={[fx.eyeRing,{right:w*.16,top:h*.28,width:eyeSize*1.32,height:eyeSize*1.05}]}/></View>;\n` +
    `  if (filter === 'ابتسامة' || filter === 'ضحكة') return <View pointerEvents="none" style={faceStyle}><View style={[fx.smile,{left:w*.31,top:h*.65,width:w*.38,height:filter==='ضحكة'?h*.13:h*.09,borderBottomWidth:filter==='ضحكة'?4:3}]}/>{filter==='ضحكة'?<><View style={[fx.cheek,{left:w*.12,top:h*.54,width:cheek,height:cheek}]}/><View style={[fx.cheek,{right:w*.12,top:h*.54,width:cheek,height:cheek}]}/></>:null}</View>;\n` +
    `  if (filter === 'وجه طفولي') return <View pointerEvents="none" style={[faceStyle,fx.babyFace]}><View style={[fx.bigEye,{left:w*.16,top:h*.27,width:eyeSize*1.4,height:eyeSize*1.2}]}/><View style={[fx.bigEye,{right:w*.16,top:h*.27,width:eyeSize*1.4,height:eyeSize*1.2}]}/><View style={[fx.cheek,{left:w*.10,top:h*.55,width:cheek,height:cheek}]}/><View style={[fx.cheek,{right:w*.10,top:h*.55,width:cheek,height:cheek}]}/></View>;\n` +
    `  if (filter === 'وجه نحيف') return <View pointerEvents="none" style={faceStyle}><View style={[fx.sideShade,{left:-w*.03,top:h*.20,width:w*.19,height:h*.58,transform:[{rotate:'8deg'}]}]}/><View style={[fx.sideShade,{right:-w*.03,top:h*.20,width:w*.19,height:h*.58,transform:[{rotate:'-8deg'}]}]}/><View style={[fx.jawLine,{left:w*.18,top:h*.72,width:w*.64,height:h*.20}]}/></View>;\n` +
    `  if (filter === 'وجه ممتلئ') return <View pointerEvents="none" style={faceStyle}><View style={[fx.fullCheek,{left:w*.05,top:h*.48,width:w*.30,height:h*.25}]}/><View style={[fx.fullCheek,{right:w*.05,top:h*.48,width:w*.30,height:h*.25}]}/></View>;\n` +
    `  if (filter === 'أنف ناعم') return <View pointerEvents="none" style={faceStyle}><View style={[fx.noseLine,{left:w*.485,top:h*.40,height:h*.25}]}/><View style={[fx.noseDot,{left:w*.47,top:h*.63}]}/></View>;\n` +
    `  if (filter === 'شفاه') return <View pointerEvents="none" style={faceStyle}><View style={[fx.lipTintStrong,{left:w*.31,top:h*.66,width:w*.38,height:h*.08}]}/></View>;\n` +
    `  if (filter === 'نمش') return <View pointerEvents="none" style={faceStyle}>{Array.from({length:14}).map((_,i)=><View key={i} style={[fx.freckle,{left:w*(.27+(i%7)*.075),top:h*(.49+(i%2)*.035)}]}/>)}</View>;\n` +
    `  if (filter === 'وردي') return <View pointerEvents="none" style={faceStyle}><View style={[fx.pinkCheek,{left:w*.08,top:h*.52,width:w*.28,height:h*.16}]}/><View style={[fx.pinkCheek,{right:w*.08,top:h*.52,width:w*.28,height:h*.16}]}/></View>;\n` +
    `  if (filter === 'نجوم') return <View pointerEvents="none" style={faceStyle}><Text style={[fx.star,{left:w*.03,top:h*.05}]}>✦</Text><Text style={[fx.starSmall,{right:w*.02,top:h*.19}]}>✧</Text><Text style={[fx.starSmall,{left:w*.08,top:h*.68}]}>✦</Text></View>;\n` +
    `  if (filter === 'مرح') return <View pointerEvents="none" style={faceStyle}><View style={[fx.funDot,{left:w*.12,top:h*.18,backgroundColor:PEACH}]}/><View style={[fx.funDot,{right:w*.12,top:h*.18,backgroundColor:TEAL}]}/><View style={[fx.funMouth,{left:w*.28,top:h*.68,width:w*.44,height:h*.13}]}/></View>;\n` +
    `  if (filter === 'كرتوني') return <View pointerEvents="none" style={[faceStyle,fx.cartoon]}><View style={[fx.cartoonEye,{left:w*.16,top:h*.28,width:eyeSize,height:eyeSize}]}/><View style={[fx.cartoonEye,{right:w*.16,top:h*.28,width:eyeSize,height:eyeSize}]}/><View style={[fx.cartoonMouth,{left:w*.34,top:h*.68,width:w*.32,height:h*.08}]}/></View>;\n` +
    `  if (filter === 'نيون أثر') return <View pointerEvents="none" style={[faceStyle,fx.neonFace]}><View style={[fx.neonLine,{left:w*.12,top:h*.31,width:w*.30,borderColor:TEAL}]}/><View style={[fx.neonLine,{right:w*.12,top:h*.31,width:w*.30,borderColor:PEACH}]}/><View style={[fx.neonMouth,{left:w*.32,top:h*.67,width:w*.36,height:h*.08}]}/></View>;\n` +
    `  if (filter === 'توقيع Sawa Up') return <View pointerEvents="none" style={faceStyle}><View style={[fx.signature,{left:w*.08,top:h*.08}]}><View style={[fx.sigDot,{backgroundColor:TEAL}]}/><View style={[fx.sigDot,{backgroundColor:PEACH,marginLeft:-5,marginTop:6}]}/></View><Text style={[fx.signatureText,{right:w*.04,top:h*.80}]}>Sawa Up</Text></View>;\n` +
    '  return null;\n' +
    '}\n' +
    '\n' +
    "const AtharLiveCamera = forwardRef(function AtharLiveCamera({ active=true, facing='back', torch=false, mode='picture', microphoneGranted=false, filter='طبيعي', onReady, onNotReady, onError }, ref) {\n" +
    '  const device = useCameraDevice(facing);\n' +
    '  const visionRef = useRef(null), startedRef = useRef(false), appliedTorchRef = useRef(false);\n' +
    '  const [started,setStarted]=useState(false), [screenFlash,setScreenFlash]=useState(false);\n' +
    "  const [appActive,setAppActive]=useState(AppState.currentState==='active');\n" +
    '  const cameraActive=active && appActive;\n' +
    '  const callbackRef=useRef({onReady,onNotReady,onError}); callbackRef.current={onReady,onNotReady,onError};\n' +
    '  const errorSeenRef=useRef(new Set());\n' +
    '  const handleError=useCallback(error=>{\n' +
    '    const message=String(error?.message||error);\n' +
    '    if(errorSeenRef.current.has(message))return;\n' +
    '    errorSeenRef.current.add(message);\n' +
    "    callbackRef.current.onError?.({message:'تعذر تشغيل الكاميرا. أغلقها وافتحها مجددًا.',cause:message});\n" +
    '  },[]);\n' +
    '  const handleStarted=useCallback(()=>{startedRef.current=true;setStarted(true);errorSeenRef.current.clear();callbackRef.current.onReady?.();},[]);\n' +
    '  const handleStopped=useCallback(()=>{startedRef.current=false;setStarted(false);callbackRef.current.onNotReady?.();},[]);\n' +
    "  useEffect(()=>{const sub=AppState.addEventListener('change',value=>setAppActive(value==='active'));return()=>sub.remove();},[]);\n" +
    "  const photoOutput = usePhotoOutput({ qualityPrioritization: 'balanced', containerFormat: 'jpeg' });\n" +
    "  const videoOutput = useVideoOutput({ enableAudio: !!microphoneGranted, fileType: 'mp4' });\n" +
    '  const viewRecorder = useViewRecorder();\n' +
    '  const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);\n' +
    '  const nativeRecorderRef = useRef(null);\n' +
    '  const filteredRecordPromiseRef = useRef(null);\n' +
    '  const filteredAbortRef = useRef(null);\n' +
    '  const filteredTimeoutRef = useRef(null);\n' +
    '  const filteredRecordingRef = useRef(false);\n' +
    '  const [face,setFace]=useState(null);\n' +
    '  const [warpError,setWarpError]=useState(false);\n' +
    '  const pipelineId=useRef(`athar-${Date.now()}-${Math.random().toString(36).slice(2)}`).current;\n' +
    "  const pipelineAvailable=Platform.OS==='android' && isFaceWarpNativeAvailable();\n" +
    '  const warpOutput=useMemo(()=>pipelineAvailable?createSawaWarpOutput(pipelineId):null,[pipelineAvailable,pipelineId]);\n' +
    '  useEffect(()=>()=>{warpOutput?.dispose?.();},[warpOutput]);\n' +
    '  const [warpReady,setWarpReady]=useState(false);\n' +
    '  const [warpFaceVisible,setWarpFaceVisible]=useState(false);\n' +
    '  const lastFaceUpdate=useRef(0);\n' +
    '\n' +
    "  const outputs = useMemo(() => [...(mode === 'video' ? [videoOutput, photoOutput] : [photoOutput]),...(warpOutput?[warpOutput]:[])], [mode,photoOutput,videoOutput,warpOutput]);\n" +
    "  const hasLiveEffect = filter !== 'طبيعي';\n" +
    '  const nativeWarpActive = pipelineAvailable && MORPH_FILTERS.has(filter);\n' +
    '  const cameraComponent = pipelineAvailable ? Camera : FaceCamera;\n' +
    '  const CameraComponent = cameraComponent;\n' +
    '\n' +
    '  useEffect(()=>{\n' +
    '    setWarpError(false);setWarpReady(false);setWarpFaceVisible(false);setFace(null);\n' +
    '    if(nativeWarpActive)ca'... 13675 more characters
  
      at TestContext.<anonymous> (file:///workspace/scratch/d220fa18dfcf/sawaup-v4/tests/v3-native-completion.test.mjs:27:10)
      at Test.runInAsyncScope (node:async_hooks:227:14)
      at Test.run (node:internal/test_runner/test:1382:25)
      at Test.processPendingSubtests (node:internal/test_runner/test:960:18)
      at Test.postRun (node:internal/test_runner/test:1522:19)
      at Test.run (node:internal/test_runner/test:1447:12)
      at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: 'import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from \'react\';\nimport { AppState, Platform, StyleSheet, Text, View } from \'react-native\';\nimport { Paths, File } from \'expo-file-system\';\nimport { RecordingPresets, setAudioModeAsync, useAudioRecorder } from \'expo-audio\';\nimport { RecordingView, useViewRecorder } from \'react-native-view-recorder\';\nimport { Camera, useCameraDevice, usePhotoOutput, useVideoOutput } from \'react-native-vision-camera\';\nimport { Camera as FaceCamera } from \'react-native-vision-camera-face-detector\';\nimport SawaFaceWarpView, { createSawaWarpOutput, isFaceWarpNativeAvailable } from \'../modules/sawaup-face-warp/src\';\n\nconst TEAL = \'#2ED2C5\';\nconst PEACH = \'#FFB680\';\n\nconst LIVE_FILTERS = new Set([\n  \'صفاء\',\'إشراقة\',\'جمال ناعم\',\'جمال+\',\'عيون\',\'ابتسامة\',\'ضحكة\',\'وجه طفولي\',\'وجه نحيف\',\'وجه ممتلئ\',\n  \'أنف ناعم\',\'شفاه\',\'نمش\',\'وردي\',\'نجوم\',\'مرح\',\'كرتوني\',\'نيون أثر\',\'توقيع Sawa Up\',\n]);\n\n// These filters require geometric deformation of camera pixels. On Android they\n// use an attached VisionCamera frame output. Filter changes never mount another\n// camera owner; the native view only detects/deforms/renders copied frames.\nconst MORPH_FILTERS = new Set([\n  \'صفاء\',\'إشراقة\',\'جمال ناعم\',\'جمال+\',\'عيون\',\'ابتسامة\',\'ضحكة\',\'وجه طفولي\',\'وجه نحيف\',\'وجه ممتلئ\',\'أنف ناعم\',\'شفاه\',\n]);\n\nfunction clampBox(bounds) {\n  if (!bounds) return null;\n  const x = Number(bounds.x ?? bounds.left ?? 0);\n  const y = Number(bounds.y ?? bounds.top ?? 0);\n  const width = Number(bounds.width ?? 0);\n  const height = Number(bounds.height ?? 0);\n  if (![x,y,width,height].every(Number.isFinite) || width < 20 || height < 20) return null;\n  return { x, y, width, height };\n}\n\nfunction FaceEffect({ face, filter }) {\n  const box = clampBox(face?.bounds);\n  if (!box || !LIVE_FILTERS.has(filter)) return null;\n  const { x, y, width:w, height:h } = box;\n  const faceStyle = { position:\'absolute\', left:x, top:y, width:w, height:h, borderRadius:Math.max(18,w*.42) };\n  const eyeSize = Math.max(12, Math.min(34, w*.16));\n  const cheek = Math.max(18, Math.min(40, w*.22));\n\n  if (filter === \'صفاء\') return <View pointerEvents="none" style={[faceStyle, fx.softSkin]} />;\n  if (filter === \'إشراقة\') return <View pointerEvents="none" style={[faceStyle,fx.faceGlow]}/>;\n  if (filter === \'جمال ناعم\' || filter === \'جمال+\') return <View pointerEvents="none" style={[faceStyle,filter===\'جمال+\'?fx.beautyPlus:fx.beautySoft]}>\n    <View style={[fx.eyeGlow,{left:w*.20,top:h*.32,width:eyeSize,height:eyeSize}]}/><View style={[fx.eyeGlow,{right:w*.20,top:h*.32,width:eyeSize,height:eyeSize}]}/>\n    <View style={[fx.lipTint,{left:w*.34,top:h*.67,width:w*.32,height:Math.max(7,h*.055)}]}/>\n  </View>;\n  if (filter === \'عيون\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.eyeRing,{left:w*.16,top:h*.28,width:eyeSize*1.32,height:eyeSize*1.05}]}/><View style={[fx.eyeRing,{right:w*.16,top:h*.28,width:eyeSize*1.32,height:eyeSize*1.05}]}/></View>;\n  if (filter === \'ابتسامة\' || filter === \'ضحكة\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.smile,{left:w*.31,top:h*.65,width:w*.38,height:filter===\'ضحكة\'?h*.13:h*.09,borderBottomWidth:filter===\'ضحكة\'?4:3}]}/>{filter===\'ضحكة\'?<><View style={[fx.cheek,{left:w*.12,top:h*.54,width:cheek,height:cheek}]}/><View style={[fx.cheek,{right:w*.12,top:h*.54,width:cheek,height:cheek}]}/></>:null}</View>;\n  if (filter === \'وجه طفولي\') return <View pointerEvents="none" style={[faceStyle,fx.babyFace]}><View style={[fx.bigEye,{left:w*.16,top:h*.27,width:eyeSize*1.4,height:eyeSize*1.2}]}/><View style={[fx.bigEye,{right:w*.16,top:h*.27,width:eyeSize*1.4,height:eyeSize*1.2}]}/><View style={[fx.cheek,{left:w*.10,top:h*.55,width:cheek,height:cheek}]}/><View style={[fx.cheek,{right:w*.10,top:h*.55,width:cheek,height:cheek}]}/></View>;\n  if (filter === \'وجه نحيف\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.sideShade,{left:-w*.03,top:h*.20,width:w*.19,height:h*.58,transform:[{rotate:\'8deg\'}]}]}/><View style={[fx.sideShade,{right:-w*.03,top:h*.20,width:w*.19,height:h*.58,transform:[{rotate:\'-8deg\'}]}]}/><View style={[fx.jawLine,{left:w*.18,top:h*.72,width:w*.64,height:h*.20}]}/></View>;\n  if (filter === \'وجه ممتلئ\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.fullCheek,{left:w*.05,top:h*.48,width:w*.30,height:h*.25}]}/><View style={[fx.fullCheek,{right:w*.05,top:h*.48,width:w*.30,height:h*.25}]}/></View>;\n  if (filter === \'أنف ناعم\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.noseLine,{left:w*.485,top:h*.40,height:h*.25}]}/><View style={[fx.noseDot,{left:w*.47,top:h*.63}]}/></View>;\n  if (filter === \'شفاه\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.lipTintStrong,{left:w*.31,top:h*.66,width:w*.38,height:h*.08}]}/></View>;\n  if (filter === \'نمش\') return <View pointerEvents="none" style={faceStyle}>{Array.from({length:14}).map((_,i)=><View key={i} style={[fx.freckle,{left:w*(.27+(i%7)*.075),top:h*(.49+(i%2)*.035)}]}/>)}</View>;\n  if (filter === \'وردي\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.pinkCheek,{left:w*.08,top:h*.52,width:w*.28,height:h*.16}]}/><View style={[fx.pinkCheek,{right:w*.08,top:h*.52,width:w*.28,height:h*.16}]}/></View>;\n  if (filter === \'نجوم\') return <View pointerEvents="none" style={faceStyle}><Text style={[fx.star,{left:w*.03,top:h*.05}]}>✦</Text><Text style={[fx.starSmall,{right:w*.02,top:h*.19}]}>✧</Text><Text style={[fx.starSmall,{left:w*.08,top:h*.68}]}>✦</Text></View>;\n  if (filter === \'مرح\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.funDot,{left:w*.12,top:h*.18,backgroundColor:PEACH}]}/><View style={[fx.funDot,{right:w*.12,top:h*.18,backgroundColor:TEAL}]}/><View style={[fx.funMouth,{left:w*.28,top:h*.68,width:w*.44,height:h*.13}]}/></View>;\n  if (filter === \'كرتوني\') return <View pointerEvents="none" style={[faceStyle,fx.cartoon]}><View style={[fx.cartoonEye,{left:w*.16,top:h*.28,width:eyeSize,height:eyeSize}]}/><View style={[fx.cartoonEye,{right:w*.16,top:h*.28,width:eyeSize,height:eyeSize}]}/><View style={[fx.cartoonMouth,{left:w*.34,top:h*.68,width:w*.32,height:h*.08}]}/></View>;\n  if (filter === \'نيون أثر\') return <View pointerEvents="none" style={[faceStyle,fx.neonFace]}><View style={[fx.neonLine,{left:w*.12,top:h*.31,width:w*.30,borderColor:TEAL}]}/><View style={[fx.neonLine,{right:w*.12,top:h*.31,width:w*.30,borderColor:PEACH}]}/><View style={[fx.neonMouth,{left:w*.32,top:h*.67,width:w*.36,height:h*.08}]}/></View>;\n  if (filter === \'توقيع Sawa Up\') return <View pointerEvents="none" style={faceStyle}><View style={[fx.signature,{left:w*.08,top:h*.08}]}><View style={[fx.sigDot,{backgroundColor:TEAL}]}/><View style={[fx.sigDot,{backgroundColor:PEACH,marginLeft:-5,marginTop:6}]}/></View><Text style={[fx.signatureText,{right:w*.04,top:h*.80}]}>Sawa Up</Text></View>;\n  return null;\n}\n\nconst AtharLiveCamera = forwardRef(function AtharLiveCamera({ active=true, facing=\'back\', torch=false, mode=\'picture\', microphoneGranted=false, filter=\'طبيعي\', onReady, onNotReady, onError }, ref) {\n  const device = useCameraDevice(facing);\n  const visionRef = useRef(null), startedRef = useRef(false), appliedTorchRef = useRef(false);\n  const [started,setStarted]=useState(false), [screenFlash,setScreenFlash]=useState(false);\n  const [appActive,setAppActive]=useState(AppState.currentState===\'active\');\n  const cameraActive=active && appActive;\n  const callbackRef=useRef({onReady,onNotReady,onError}); callbackRef.current={onReady,onNotReady,onError};\n  const errorSeenRef=useRef(new Set());\n  const handleError=useCallback(error=>{\n    const message=String(error?.message||error);\n    if(errorSeenRef.current.has(message))return;\n    errorSeenRef.current.add(message);\n    callbackRef.current.onError?.({message:\'تعذر تشغيل الكاميرا. أغلقها وافتحها مجددًا.\',cause:message});\n  },[]);\n  const handleStarted=useCallback(()=>{startedRef.current=true;setStarted(true);errorSeenRef.current.clear();callbackRef.current.onReady?.();},[]);\n  const handleStopped=useCallback(()=>{startedRef.current=false;setStarted(false);callbackRef.current.onNotReady?.();},[]);\n  useEffect(()=>{const sub=AppState.addEventListener(\'change\',value=>setAppActive(value===\'active\'));return()=>sub.remove();},[]);\n  const photoOutput = usePhotoOutput({ qualityPrioritization: \'balanced\', containerFormat: \'jpeg\' });\n  const videoOutput = useVideoOutput({ enableAudio: !!microphoneGranted, fileType: \'mp4\' });\n  const viewRecorder = useViewRecorder();\n  const audioRecorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);\n  const nativeRecorderRef = useRef(null);\n  const filteredRecordPromiseRef = useRef(null);\n  const filteredAbortRef = useRef(null);\n  const filteredTimeoutRef = useRef(null);\n  const filteredRecordingRef = useRef(false);\n  const [face,setFace]=useState(null);\n  const [warpError,setWarpError]=useState(false);\n  const pipelineId=useRef(`athar-${Date.now()}-${Math.random().toString(36).slice(2)}`).current;\n  const pipelineAvailable=Platform.OS===\'android\' && isFaceWarpNativeAvailable();\n  const warpOutput=useMemo(()=>pipelineAvailable?createSawaWarpOutput(pipelineId):null,[pipelineAvailable,pipelineId]);\n  useEffect(()=>()=>{warpOutput?.dispose?.();},[warpOutput]);\n  const [warpReady,setWarpReady]=useState(false);\n  const [warpFaceVisible,setWarpFaceVisible]=useState(false);\n  const lastFaceUpdate=useRef(0);\n\n  const outputs = useMemo(() => [...(mode === \'video\' ? [videoOutput, photoOutput] : [photoOutput]),...(warpOutput?[warpOutput]:[])], [mode,photoOutput,videoOutput,warpOutput]);\n  const hasLiveEffect = filter !== \'طبيعي\';\n  const nativeWarpActive = pipelineAvailable && MORPH_FILTERS.has(filter);\n  const cameraComponent = pipelineAvailable ? Camera : FaceCamera;\n  const CameraComponent = cameraComponent;\n\n  useEffect(()=>{\n    setWarpError(false);setWarpReady(false);setWarpFaceVisible(false);setFace(null);\n    if(nativeWarpActive)ca'... 13675 more characters,
    expected: /onFacesDetected=/,
    operator: 'match',
    diff: 'simple'
  }
