# PropertyLab Sales Agent Flutter app design

Date: 2026-08-21

Status: approved

Product name: **PropertyLab Sales Agent**

Delivery order: Android Pilot → Android Production → iOS

## 1. Objective

Build a clean Flutter sales app that makes one workflow reliable from end to
end:

```text
Search a petaV3 Lead
→ choose Phone Call or WhatsApp Call
→ start the YHY02 recorder from the phone or its physical button
→ conduct the call
→ stop the recorder from the phone or its physical button
→ upload the AAC recording automatically
→ deterministically attach the recording to the selected Lead
```

The same Flutter repository will ultimately ship Android and iOS versions with
the same user-visible screens, state machine, API contracts, and outcomes.
Android is implemented and piloted first because the YHY02 BLE protocol has
already been validated there. iOS follows by implementing the same platform
contracts with Core Bluetooth and iOS background facilities.

The new app uses petaV3 exclusively. It contains no petaV2 authentication,
tokens, endpoints, fallback behavior, or migration compatibility layer.

## 2. Product decisions

The following decisions are approved:

1. Create a new Flutter repository, proposed name
   `propertylab-sales-agent`.
2. Ship a separately installable Android Pilot build first. After pilot
   acceptance, the Production flavor adopts the existing Android application
   ID and signing identity so it can replace the Kotlin app in place.
3. Build iOS from the same Flutter repository after Android Production is
   stable.
4. Salespeople sign in with their existing petaV3 employee account. petaV2 is
   not used.
5. Search the full petaV3 Lead dataset by name, phone, email, and other
   approved indexed identifiers. Search is server-side and cursor-paginated;
   the full database is never downloaded to a phone.
6. A call is eligible for automatic Lead matching only when the salesperson
   selects the Lead in PropertyLab Sales Agent before recording starts.
7. Calls initiated outside PropertyLab Sales Agent are out of scope. The app
   does not read the system CallLog or attempt to infer unrelated calls.
8. The Phone action opens the system phone experience.
9. The WhatsApp action opens the selected Lead's `wa.me` chat. WhatsApp has no
   supported consumer-app deep link that directly starts a voice call, so the
   salesperson taps WhatsApp's voice-call button after the chat opens.
10. The salesperson only needs to power the YHY02 recorder on or off
    physically. Recording start and stop may each be triggered either from the
    app or from the recorder's physical button.
11. If the badge is unavailable or an app-initiated recording start is not
    confirmed, the app blocks the phone/WhatsApp launch.
12. Wi-Fi and mobile data are both permitted for automatic upload.
13. After petaV3 durably acknowledges an upload, the app deletes its local AAC
    cache but retains the original file on the badge.
14. The first release never sends YHY02 command `0x0043` and exposes no device
    delete, format, hotspot, or OTA action.
15. The supplied house logo is the approved brand direction. It will be
    redrawn faithfully as a 1024×1024 production master while preserving its
    house shape and blue, gray, and black palette.
16. A normal Lead-linked interaction must receive a petaV3 create
    acknowledgement for the same client UUID before the app starts the badge
    or launches Phone/WhatsApp. This prevents two offline phones for one agent
    from creating irreconcilable Lead/recording claims. While offline, the app
    may still protect an unexpected badge recording as unassigned, but it does
    not promise normal automatic matching until the user reconnects and
    explicitly assigns a Lead.

## 3. Scope and non-goals

### First-release scope

- petaV3 login and refresh-token lifecycle;
- server-side Lead search and Lead detail;
- Phone Call and WhatsApp Call launch actions;
- YHY02 discovery, binding, reconnect, device information, and recording
  control;
- live AAC capture and badge-history recovery;
- deterministic interaction-to-recording-to-Lead linkage;
- persistent offline interaction and upload queues;
- automatic upload on Wi-Fi or mobile data;
- Sessions, Badge, and Settings screens; and
- Pilot and Production build flavors.

### Explicit non-goals

- petaV2 support;
- system contact-book import or synchronization;
- detection of calls started from the system phonebook or directly inside
  WhatsApp;
- WhatsApp text messaging or inbox functionality;
- Dashboard, Lead editing, or general CRM administration;
- reading Android CallLog or WhatsApp notifications;
- claiming that an externally launched call was answered;
- automatic deletion of badge files;
- YHY02 formatting, SoftAP/hotspot control, OTA, or destructive maintenance;
- starting two active interactions at once; and
- downloading the complete Lead database for offline browsing.

## 4. Approaches considered

### A. Flutter-first with thin native adapters — selected

Flutter owns presentation, application state, domain rules, API access, local
persistence, and the interaction/upload state machines. Android and iOS expose
small platform implementations for BLE, background execution, secure storage,
and external call launch.

This gives both platforms one product implementation while still permitting
the native lifecycle work required for reliable background BLE.

### B. Wrap the current Kotlin BLE implementation as an Android Flutter plugin

This can shorten the initial Android port, but it makes the clean app depend on
the old app's lifecycle and forces iOS to receive a separate implementation
later. It is rejected as the product architecture. The tested Kotlin behavior
and golden protocol frames remain valuable references and may be ported
surgically.

### C. WebView lead UI with a native BLE shell

This would reuse more web presentation code but makes navigation, background
BLE, local recovery, and upload state harder to reason about. It is rejected.

## 5. Architecture

```text
┌─────────────────────────────────────────────────────────────┐
│ Flutter presentation                                       │
│ Login · Leads · Lead Detail · Active Session · Sessions    │
│ Badge · Settings                                           │
├─────────────────────────────────────────────────────────────┤
│ Flutter application/domain                                 │
│ Auth · Lead Search · Interaction Coordinator               │
│ Recording Session State Machine · Upload Coordinator       │
├─────────────────────────────────────────────────────────────┤
│ Flutter data                                               │
│ petaV3 API · SQLite outbox · private AAC store             │
│ token facade · retry policy                                │
├─────────────────────────────────────────────────────────────┤
│ Platform contracts                                         │
│ BadgeController · BackgroundUploader · ExternalCallLauncher│
│ SecureTokenStore · AppLifecycleBridge                      │
├───────────────────────────┬─────────────────────────────────┤
│ Android Kotlin adapters   │ iOS Swift adapters             │
│ BLE/FGS/WorkManager       │ CoreBluetooth/BG tasks/Keychain│
└───────────────────────────┴─────────────────────────────────┘
```

Flutter code must not import Android or iOS APIs directly. Platform behavior is
reachable only through explicit interfaces with fake implementations for unit
tests.

The shared business state machine is authoritative. Platform callbacks are
events into that state machine, not independent sources of product truth.

## 6. Platform contracts

### `BadgeController`

Responsibilities:

- scan only for supported YHY02 devices;
- bind a badge serial to the signed-in salesperson;
- connect, negotiate, subscribe, and reconnect;
- expose device information, recording state, elapsed time, and failures;
- start and stop recording with explicit acknowledgement;
- own the ordered native AAC capture file for the entire recording session and
  expose progress, sequence-gap, and finalized-file events to Flutter;
- recover a selected file from device history; and
- preserve connection-generation isolation so callbacks from an old GATT/
  CoreBluetooth session cannot contaminate a new session.

It must not expose destructive commands to Flutter UI code.

### `BackgroundUploader`

Responsibilities:

- schedule immediate upload after file finalization;
- retry when either Wi-Fi or mobile data becomes available;
- resume safely after process termination;
- enforce one upload per idempotency key; and
- report durable status back to the shared SQLite ledger.

Android initially uses WorkManager plus the connected-device foreground
service needed by active BLE. iOS later implements equivalent outcomes through
Core Bluetooth background mode, restoration, and permitted background upload
facilities. The operating-system mechanisms may differ; product states do not.

On Android, the connected-device foreground service is the single AAC writer
for the entire active session, whether the Flutter Activity/engine is foreground
or background. File ownership never transfers mid-session. It owns the minimum
active capture journal and cannot depend only on an Activity-owned or cached
Flutter engine: that engine dies with the process. Dart reconciles the native
journal into SQLite on startup.
Ordinary backgrounding and a recoverable OS process death are tested
separately from a user force-stop. Android does not restart work after a user
force-stop until the user opens the app again, so that case recovers from the
badge history on the next explicit launch rather than claiming immediate
background finalization.

### `ExternalCallLauncher`

Responsibilities:

- launch `tel:` for a normalized Lead phone number;
- launch `https://wa.me/<international_digits>` for WhatsApp;
- report whether the operating system accepted the launch; and
- never create or complete an interaction on its own.

### `SecureTokenStore`

Stores petaV3 credentials only through Android Keystore-backed storage or iOS
Keychain-backed storage. SQLite must never contain raw passwords or bearer
tokens.

## 7. Lead search

The Leads tab queries petaV3 rather than importing the database. The endpoint
searches the full product-approved customer Lead scope for the authenticated
staff role. Existing petaV3 Sales Agent defaults grant only
`VIEW_LEADS_OWN`, while granting the existing `VIEW_LEADS_ALL` would also widen
the salesperson's web access. Add a dedicated agent-app full-search permission
used only by this API and grant it explicitly to the approved sales roles. The
endpoint excludes staff identities, merged/deleted rows, and any other existing
non-customer exclusions; it never obtains broad access merely because a request
came from a mobile client.

The minimal result contains:

```text
lead_uuid
display_name
primary_phone
email
lead_status
assigned_salesperson (when available)
updated_at
```

Search requirements:

- case-insensitive name search;
- normalized Malaysian and international phone search;
- email search;
- cursor pagination with deterministic ordering;
- an explicit agent-app full-search permission, separate from web Lead
  visibility grants;
- cancellation of stale typeahead requests;
- no raw SQL wildcard construction in the client;
- bounded local cache of recent searches and recently opened Leads; and
- server authorization on both search and individual Lead lookup.

An empty or enumeration-friendly query is not a download mechanism. Name and
email queries have a minimum useful length; phone search has a separately
defined minimum digit length. Pages are small, cursors are short-lived and
bound to the authenticated user/query, and the endpoint has a dedicated
per-user throttle plus security audit telemetry for abnormal enumeration.

### Badge binding

The agent API exposes the authenticated salesperson's badge binding and an
idempotent first-bind operation. A bind succeeds only for a pre-provisioned
inventory badge that is unowned or already belongs to the same Admin, after the
app has connected and confirmed the normalized serial from the device. Unknown
serials cannot be created by this endpoint. The connection check is proximity
evidence, not cryptographic hardware attestation, so every bind is audited. A
badge owned by another Admin returns a conflict and cannot be silently
transferred. Transfer remains a manager/audited operation outside the
first-release app. Disconnecting locally does not remove server ownership.

The client sends `lead_uuid`, never a trusted internal `lead_id`, when creating
an interaction. The server resolves and authorizes the Lead.

## 8. Interaction lifecycle

The client generates a UUID before any network or BLE side effect and persists
it locally. This UUID is the idempotency key across app restarts and unreliable
mobile networks.

### Shared states

```text
PREPARING
→ WAITING_FOR_BADGE_START (physical-start path only)
→ RECORDING
→ STOPPING
→ QUEUED
→ UPLOADING
→ MATCHED
```

Terminal alternatives:

```text
CANCELED · EXPIRED · FAILED_PERMANENT · FILTERED
```

`RECONNECTING` is a recoverable substate of an active recording session, not a
terminal state.

Only one active-capture interaction (`PREPARING`, `WAITING_FOR_BADGE_START`,
`RECORDING`, or `STOPPING`) may exist for one signed-in salesperson. Completed
captures in `QUEUED`/`UPLOADING` do not block the next call; several uploads may
wait offline. The Flutter client enforces this locally, and the petaV3 create
transaction enforces it across multiple phones by locking the authenticated
Admin row before checking for an existing active-capture Flutter interaction.
Attempts to start another return `409` with the existing Active Session UUID
instead of creating a second row or pretending the new UUID succeeded.

### App-start path

1. Salesperson selects a Lead and Phone Call or WhatsApp Call.
2. Client persists `PREPARING` with a new `interaction_uuid` and sends the
   idempotent server create.
3. The app requires a create acknowledgement for that same UUID, then verifies
   that the badge is connected and ready.
4. App writes YHY02 `0x0022 Start` and waits for a successful ACK plus recording
   state confirmation.
5. State becomes `RECORDING` with `start_source=app`.
6. Only then does the app launch the external phone or WhatsApp experience.
7. If the launch fails, the app stops the recorder and marks the interaction
   `CANCELED`.

### Physical-start path

1. Salesperson first selects a Lead and channel.
2. Client persists the interaction, requires a server create acknowledgement
   for the same UUID, and enters `WAITING_FOR_BADGE_START`.
3. UI instructs the salesperson to press the badge button.
4. A validated recording-state notification moves the interaction to
   `RECORDING` with `start_source=device_button`.
5. The app launches the selected external call experience.
6. If no recording starts within ten minutes, the interaction becomes
   `EXPIRED` and no external call is launched.

The waiting screen must remain foregrounded until the physical start is
confirmed. Android and iOS both restrict unsolicited background activity
launches, so a badge callback received after the salesperson leaves this screen
may confirm recording but must not open Phone or WhatsApp without a fresh user
action.

Selecting the Lead before physical start is mandatory. If the badge is started
unexpectedly with no selected Lead, the app captures the audio safely as
unassigned but does not guess or upload it as a matched call. The Sessions tab
requires an explicit Lead assignment before it may enter the normal upload
flow.

### Stop paths

Both paths invoke one idempotent finalizer:

- **App stop:** the Active Session button writes `0x0022 Stop`; successful ACK
  and stopped status set `stop_source=app`.
- **Physical stop:** a validated badge notification reporting stopped sets
  `stop_source=device_button`; the app finalizes and queues automatically even
  while backgrounded.
- **Recovery stop:** if a disconnect or process death hides the stop event,
  state restoration and targeted history recovery finalize the file with
  `stop_source=recovery`.

Nearly simultaneous app and physical stops must still close one file, perform
one state transition, and enqueue one upload.

The system records a sales call session and its recording. It does not claim
that the customer answered merely because an external app opened.

## 9. YHY02 protocol and safety

The authoritative protocol defines recording control on characteristic FEE3:

```text
command: 0x0022
payload: RecCmd 1B + Unix timestamp BE32 + timezone 1B
RecCmd: 0x01 Start, 0x00 Stop
ACK: Result 1B + RecStatus 1B
```

Recording-state notifications arrive on FEE3 and live AAC arrives on FEE4.

Before this command is exposed to any Pilot user, a bounded hardware validation
must prove on YHY02_5BA8E1:

- app Start → ACK success → recording notification → valid FEE4 AAC;
- app Stop → ACK success → stopped notification → valid closed AAC;
- app Start + physical Stop;
- physical Start + app Stop;
- repeated start/stop idempotency or documented error behavior;
- disconnect during control and reconnect recovery; and
- device clock/timestamp behavior.

Validation must not send `0x0043`, `0x0032`, `0x0030`, OTA, or any undocumented
command.

The production command surface is an allow-list containing only the commands
required for device info, recording control, current stream, history index, and
targeted recovery. Destructive command constants must not be reachable from UI
or ordinary session orchestration.

## 10. petaV3 API design

The new endpoints follow the existing JWT-protected agent API conventions.
petaV3 remains the authority for the authenticated user, Admin record, Lead
visibility, badge ownership, and final linkage.

### Lead search

```text
GET /api/agent-leads?q=<query>&cursor=<cursor>
```

Returns a bounded page and opaque next cursor. Search and lookup must be
authorized server-side.

### Create interaction

```text
POST /api/agent-call-interactions
```

Conceptual request:

```json
{
  "interaction_uuid": "client-generated-uuid",
  "lead_uuid": "authorized-lead-uuid",
  "call_channel": "pstn|whatsapp",
  "badge_sn": "C8478C5BA8E1",
  "status": "preparing",
  "started_at": "ISO-8601"
}
```

The server derives the Admin from JWT, resolves the Lead by UUID, verifies
visibility and badge ownership, snapshots only the approved display/phone data,
and creates or returns the same row idempotently.

The implementation should extend the existing `agent_call_events` domain
instead of creating an unrelated call system. The client
`interaction_uuid` maps to the existing `external_id` column as
`flutter:<uuid>` and reuses the existing `(admin_id, external_id)` unique key;
it does not require a second duplicate UUID column. The API continues to expose
the unprefixed interaction UUID. The selected Lead is explicit, and the source
is a new Flutter app interaction source.

Persist interaction status and start/stop sources as project-style integer
constants while exposing stable string codes in the API. Creating an
interaction locks the authenticated Admin row, checks for an existing active
Flutter interaction, and either returns a conflict identifying that resource or
inserts the new one. This is the server-side one-active-session invariant
across two phones or concurrent requests.

### Update interaction

```text
PATCH /api/agent-call-interactions/{interaction_uuid}
```

Only valid forward transitions are accepted. Client timestamps are evidence,
not authorization. The server rejects ownership mismatches, state regression,
and a second recording link.

The create response distinguishes idempotency from a conflicting active
session. Repeating the same UUID with the same immutable Lead/channel/badge
returns the same resource. Reusing that UUID with different immutable fields
returns `409`. A different UUID while another session is active also returns
`409` with the existing interaction UUID; it never pretends that the requested
UUID was created.

Active rows have server-managed liveness. `PREPARING` and
`WAITING_FOR_BADGE_START` use the approved ten-minute lease. Recording and
stopping rows use a 24-hour recovery lease, refreshed by valid transitions/
heartbeat, so a lost or uninstalled client cannot lock an account forever.
`QUEUED` and `UPLOADING` are not active-capture locks. Under the Admin row lock,
create first terminalizes an expired stale active row. Expiring a stale
recording preserves a recovery/audit trail and never deletes badge or phone
audio.

Automatic Lead matching is supported for an offline continuation only while
the recording/stopping lease remains recoverable (24 hours from the last valid
server activity). Audio returning after that boundary is never silently linked
to the expired event, especially if a later interaction exists. The client
retains/re-recovers it as unassigned and requires an explicit Lead selection;
after any current active capture ends, that action creates a new recovery
interaction UUID and uploads against it. The expired row remains as audit
evidence.

### Upload recording

```text
POST /api/agent-recordings
```

The existing multipart ingest adds:

```text
interaction_uuid
audio_sha256
```

Existing `device_sn`, `file_name`, `started_at`, `duration_seconds`, and audio
remain. Idempotency stays anchored to the recording identity and is also
consistent with the interaction link.

On upload, petaV3 verifies:

- authenticated Admin owns the badge;
- authenticated Admin owns the interaction;
- interaction resolves to the selected authorized Lead;
- interaction has no conflicting recording;
- hash and media metadata are valid; and
- duplicate retries return the original durable result.

The server persists the computed SHA-256 and byte count. A duplicate recording
identity is idempotent only when the interaction, hash, and byte count agree;
different content under the same `(device_sn, file_name)` is a conflict.

The deterministic link is one transaction with a consistent lock order:
interaction first, then recording. It may fill an empty link or accept the same
existing link, but it must reject a different recording already attached to the
interaction and reject the recording already attached to a different event.
Before adding a nullable unique constraint on
`agent_call_events.call_recording_id`, migration preflight must audit existing
duplicates; if production data is not clean, keep the transactional invariant
for the Flutter path and repair legacy duplicates before tightening the schema.
Staging and Production each require an owner-recorded decision: execute and
verify an online unique-index operation from the approved runbook, or grant a
time-bounded defer with named owner, risk, and deadline. A silent indefinite
defer is not an accepted completion state.

A duplicate `(device_sn, file_name)` upload must also compare its existing
interaction link. It returns the original durable response for the same
interaction and returns a conflict for a different interaction; a retry must
never silently reassign an existing recording.

The existing duration filter remains meaningful. A too-short upload may return
a durable `filtered` 2xx stub, but the interaction is marked filtered rather
than represented as a successfully matched audio recording. The client may
delete its local file after this durable outcome while displaying the reason.

For this Flutter path, `interaction_uuid` is deterministic evidence. The server
links `agent_call_event ↔ call_recording ↔ lead` directly. The legacy
salesperson/phone/time matcher may remain for old sources but is not the normal
matching path for PropertyLab Sales Agent.

## 11. Local persistence and automatic upload

SQLite is the durable device ledger. At minimum it stores:

### Interaction row

```text
interaction_uuid
lead_uuid
lead display snapshot
call_channel
badge_sn
state
start_source
stop_source
started_at
ended_at
last_error
updated_at
```

### Upload row

```text
upload_uuid
interaction_uuid
relative private file path
device file name
sha256
byte count
duration_seconds
state
attempt_count
server_recording_id
lease_owner
lease_expires_at
pending_delete
last_error
updated_at
```

### Interaction mutation row

```text
mutation_uuid
interaction_uuid
sequence
operation (create|transition|heartbeat)
payload
state
attempt_count
lease_owner
lease_expires_at
last_error
updated_at
```

Files live only under the app's private data container. Persist paths as strict
relative paths and resolve them with canonical-containment checks before every
read, upload, or deletion.

Upload behavior:

1. Finish and fsync a temporary AAC file.
2. Atomically publish the final local file.
3. Insert/update the upload row transactionally as `QUEUED`.
4. Attempt immediate background upload on either Wi-Fi or mobile data.
5. Retry transient network and 5xx failures with bounded exponential backoff
   and jitter.
6. Refresh petaV3 JWT once on 401. If refresh fails, retain data and require
   login.
7. Treat permanent validation/authorization failures as visible
   `FAILED_PERMANENT`, not an infinite retry loop.
8. Atomically claim work with a database lease so foreground and WorkManager
   executors cannot upload the same row concurrently.
9. On any durable petaV3 2xx result, record the server ID and a pending-delete
   marker before deleting the local AAC; startup cleanup safely finishes a
   deletion interrupted by process death.
10. Never delete the badge copy automatically.

Interaction create and transition mutations are also a durable ordered outbox.
Recording upload cannot run before the server has acknowledged the same
interaction UUID and all required preceding transitions. A server conflict is
visible and is never rebound to a different local Lead or audio file.

The client must stream multipart data from disk and never load an entire long
recording into memory.

## 12. Disconnect, process death, and history recovery

BLE live capture is an optimization for immediacy; the badge's stored file is
the recovery authority.

If BLE disconnects during an active interaction:

- preserve the active interaction and elapsed baseline;
- close the current local stream safely;
- surface `RECONNECTING` rather than pretending the badge is connected;
- reconnect using a new connection generation;
- query device/recording state; and
- after stop, inspect history only for the active interaction's expected time
  range and file identity.

The recovery path must not bulk-associate arbitrary badge files to the last
Lead. A unique candidate may be attached to the active interaction; ambiguity
becomes an unassigned recording requiring human selection.

On process restart, initialize local storage before background workers, restore
the one active interaction, restore the platform BLE manager, and reconcile
local, server, and badge state idempotently.

On iOS, ordinary suspension/restoration and a user force-quit are different.
Core Bluetooth cannot relaunch the app after a user force-quit. Long upload is
implemented with a native background `URLSession` whose task IDs are persisted
and reconciled on relaunch; it is not modeled as an immediately guaranteed
generic background task. A user force-quit falls back to next-launch badge
history reconciliation.

## 13. User experience

### Login

- PropertyLab Sales Agent branding;
- petaV3 email and password;
- clear authentication, connectivity, and account-link errors; and
- no environment URL editing in Production UI.

### Leads tab

- search field for name, phone, or email;
- paginated result list;
- Lead status and essential identity fields; and
- Lead detail with prominent Phone Call and WhatsApp Call actions.

### Start interaction

After selecting a channel, offer the two approved starts:

- start recording from the phone; or
- wait for the salesperson to press the recorder button.

The screen shows badge readiness and never launches the external call until a
recording start is confirmed.

### Active Session

- selected Lead, phone, and channel;
- badge connection and recording state;
- monotonic elapsed time, calibrated by device status without moving backward;
- prominent **End and upload** action;
- persistent Android recording notification; and
- restoration when the salesperson returns from Phone or WhatsApp.

Physical stop automatically replaces this screen with upload progress; no
second confirmation is required.

### Sessions tab

- active, queued, uploading, matched, failed, and unassigned groups;
- explicit retry for retryable failures;
- server recording identity after success;
- explicit Lead assignment for an unexpected unassigned recording; and
- no device-delete action in the first release.

### Badge tab

- scan, bind, connect, and disconnect;
- serial number, firmware, battery, storage, and current recording state;
- safe start/stop controls; and
- no delete, format, hotspot, or OTA controls.

### Settings tab

- signed-in petaV3 account;
- badge binding summary;
- permission and network diagnostics;
- Pilot/Production build identity; and
- logout with cancellation-safe BLE shutdown while preserving durable uploads.

## 14. Error behavior

- **Badge unavailable:** block launch and show the concrete recovery action.
- **Start rejected or timed out:** keep the call unlaunched and allow retry or
  cancel.
- **External launch rejected:** stop recording and cancel interaction.
- **Physical start before Lead selection:** save as unassigned; never guess.
- **Sequence gap in live AAC:** mark the live file suspect and prefer targeted
  history recovery.
- **Storage write failure:** stop capture, retain interaction evidence, and
  display a durable error.
- **Unexpected BLE disconnect:** clear connected UI immediately, retain the
  interaction, and enter recovery.
- **Stop event race:** one idempotent finalizer wins; all other signals observe
  its result.
- **Offline before server create ACK:** block a normal Lead-linked start and
  external launch. Preserve any unexpected badge recording as unassigned for
  explicit assignment after reconnect.
- **Offline after server create ACK:** continue the active local recording,
  queue transitions/upload, and reconcile when connectivity returns.
- **Duplicate API request:** return the same server resource.
- **Unauthorized Lead or badge:** permanent visible failure; never retry under a
  different identity.
- **Logout:** stop active platform services safely; do not silently discard
  queued recordings or expose them to the next account.

## 15. Security and privacy

- petaV3 JWT is the only identity credential.
- Every Lead lookup, interaction mutation, and recording upload is authorized
  server-side.
- Admin identity is always derived from JWT.
- Badge ownership is verified at every recording ingest.
- Lead and recording identifiers use UUIDs at the client boundary.
- Local Lead cache is bounded and contains only fields required by the UI.
- Tokens use platform secure storage; recordings and SQLite use private app
  storage.
- Android backup/data-extraction rules exclude AAC, Drift, token, and native
  session journals. iOS marks AAC/SQLite as excluded from device backup and
  uses an appropriate ThisDeviceOnly Keychain accessibility class.
- Logs must not print bearer tokens, audio content, full Lead records, or raw
  multipart bodies.
- Upload and state APIs require TLS in Pilot and Production.
- The app must clearly indicate when recording is active.
- Recording notice, consent, retention, access, and deletion policy require
  business/privacy review before any real-customer Pilot recording and again
  before production distribution in each jurisdiction.

## 16. Test strategy

### Shared Dart tests

- interaction state transition table;
- one-active-interaction invariant;
- app/physical start and stop combinations;
- start-before-external-launch ordering;
- blocked launch when start is not confirmed;
- stop race idempotency;
- process restart reconciliation;
- unassigned recording behavior;
- upload retry classification;
- JWT refresh behavior;
- strict relative-path containment; and
- API serialization and idempotency keys.

### Protocol and platform contract tests

- golden real-device frames from the authoritative YHY02 validation;
- frame length, direction, command, sequence, and status validation;
- connection-generation isolation;
- upstream notification readiness before enabling CCCD;
- FIFO preservation of received tail frames before disconnect propagation;
- Android foreground service and WorkManager behavior;
- iOS Core Bluetooth restoration and background callback behavior; and
- fake platform adapters that run the same workflow suite on both platforms.

### petaV3 tests

- Lead search by name, phone variants, and email;
- cursor stability, customer-only scope, dedicated mobile-search permission,
  and authorization;
- minimum query rules, per-user throttling, cursor binding/expiry, audit events,
  and abnormal enumeration detection;
- proof that the new permission does not widen the same user's web Lead
  visibility;
- interaction create idempotency and concurrent duplicate requests;
- Lead and badge ownership rejection;
- valid forward transitions and rejected regression;
- deterministic recording link by interaction UUID;
- prevention of one interaction linking two recordings;
- duplicate recording upload response;
- duplicate identity with different hash/size returning conflict;
- streamed multipart ingest and hash validation;
- existing analysis pipeline dispatch; and
- legacy time-based matcher behavior remaining intact for old sources.

### Mandatory real-device matrix

Run Phone and WhatsApp scenarios for each start/stop pair:

| Start | Stop |
|---|---|
| App | App |
| App | Badge button |
| Badge button | App |
| Badge button | Badge button |

Also verify:

- BLE disconnect and history recovery;
- force-stop/process death and restoration;
- ordinary background, recoverable OS kill, and user force-stop recorded as
  distinct cases with the documented recovery outcome;
- Wi-Fi upload;
- mobile-data upload;
- connectivity loss after server create ACK followed by queued retry, plus
  blocked normal start when offline before create ACK;
- long recording without memory growth;
- duplicate upload without duplicate server rows;
- successful petaV3 Lead/interaction/recording linkage;
- local file deletion only after durable acknowledgement; and
- badge file retention after upload.

## 17. Delivery and rollout

### Phase 0 — bounded recorder-control validation

Validate only the approved `0x0022` start/stop surface on the known YHY02
sample. Stop if observed behavior conflicts with the authoritative protocol.

### Phase 1 — petaV3 contract

Implement and deploy Lead search, interaction state, and deterministic upload
linkage before distributing the Flutter Pilot.

### Phase 2 — Android Pilot

Create the Flutter repository and separately installable Pilot flavor. Complete
the Android adapters and full acceptance matrix with test accounts and a test
badge.

Separate installation does not mean simultaneous BLE ownership. The existing
Kotlin app and Flutter Pilot must never run active badge services against the
same YHY02 at once. Pilot setup explicitly disconnects/stops the old app first,
or uses a dedicated test phone/badge, and verifies only one process owns the
connection.

### Phase 3 — Android Production replacement

Pilot with a bounded sales group, review failed/unassigned rates, then build
with the existing Production application ID and signing identity. Preserve a
tested rollback APK and server backward compatibility during rollout.

Test an in-place upgrade from every supported Kotlin release. The Flutter
bootstrap must inventory and either migrate or explicitly retire the old
WorkManager jobs, databases, preferences, and files that remain under the same
application ID. Production `versionCode`, data compatibility, and rollback
rules are verified before rollout; a fresh-install Pilot test is not a
substitute for an upgrade test.

If the existing Kotlin app has durable pending uploads at replacement time,
either migrate them explicitly or require an audited zero-pending cutover. The
new Flutter install must not silently orphan them.

### Phase 4 — iOS

Implement Swift platform adapters against the already-tested Flutter contracts,
run the same product and hardware matrix, distribute through TestFlight, and
release only after background BLE recovery meets the same outcome guarantees.

## 18. Branding deliverables

The supplied source icon is a 150×150 PNG without alpha. It is a reference, not
a production master.

Before Pilot packaging:

- redraw it faithfully at 1024×1024;
- preserve the house geometry and blue/gray/black palette;
- create Android adaptive foreground/background assets with safe-zone padding;
- create the complete iOS AppIcon set without transparency;
- create a clearly distinguishable Pilot variant; and
- keep the master asset in the Flutter repository so generated icons are
  reproducible.

## 19. Acceptance definition

The Android release is ready to replace the Kotlin app only when a salesperson
can select a petaV3 Lead, start and stop the badge through either approved
control path, complete either call channel, survive disconnect/offline/process
death scenarios, and see exactly one recording attached to the intended Lead
in petaV3 without manually uploading or matching it.

The iOS release is accepted against the same sentence. Platform-specific
mechanisms may differ, but the observable workflow and durable server result
must be the same.
