# AGENTS.md This file provides guidance to AI Agents like Claude Code (claude.ai/code) or OpenAI Codex when working with code in this repository. ## Project Standalone Android port of Google's **Quick Share / Nearby Share** protocol, modelled on [NearDrop](https://github.com/grishka/NearDrop). The goal is to send and receive files between this app and any existing Quick Share peer (stock Android Quick Share, NearDrop on macOS, Quick Share on Windows) **without depending on Google Play Services for the protocol logic**. The wire spec we target is the one documented at . Apple-side interop (AirDrop, AWDL, iPhone discovery) is explicitly **out of scope**. Phase 1 (Wi-Fi LAN parity with NearDrop) is complete; Phase 2 will add BLE auto-discovery. ## Common commands ```bash # Build the debug APK. ./gradlew :app:assembleDebug # Run the JVM-only protocol tests (no emulator). ./gradlew :core-protocol:test # Lint + style on all modules (single source of truth — wired by the root build). ./gradlew staticAnalysis # alias for ktlintCheck + detekt across every subproject # Full check (lint + style + tests across every module). ./gradlew check # Run one test class on the JVM. ./gradlew :core-protocol:test --tests '*.crypto.HkdfTest' # Run one test method (Kotlin backtick names need quoting; in Gradle, plain method # name with spaces in single quotes works). ./gradlew :core-protocol:test --tests 'dev.bluehouse.libredrop.protocol.crypto.HkdfTest.RFC 5869 test case 1' # Auto-format ktlint violations. ./gradlew ktlintFormat ``` CI (`.github/workflows/ci.yml`) runs `staticAnalysis`, `:core-protocol:test`, `:app:testDebugUnitTest`, and `:app:assembleDebug` on JDK 17. ### On-device debugging Several diagnostic logcat tags are wired up for real-device testing — useful when discovery or transfer behaves differently from JVM tests: ```bash adb logcat -s LibreDropDiscovery LibreDropSend LibreDropOutbound LibreDropBleScan LibreDropBleAdv LibreDropMdnsGate ``` If a manufacturer's logcat filter swallows the app's `Log.i` output (vivo Funtouch OS does this), `OutboundConnection`'s logger uses `Log.e` and also appends to `getExternalFilesDir(null)/libredrop-outbound.log` — pull it with: ```bash adb shell cat /sdcard/Android/data/dev.bluehouse.libredrop.debug/files/libredrop-outbound.log ``` ### Debug Loop Debug loop spawns when testing on actual devices are required. Unless user explicitly calls out for, this function won't be executed. For debug loop testing, two android devices will be connected - one with vanila GMS (e.g. Samsung, Pixel, ...) and one without vanila GMS (Oppo, Vivo, ...).Install LibreDrop on the phone without GMS and use adb's UIAutomator/Screen Dump to automate test steps. OriginOS (FuntouchOS) specific: APK installtion step stalling for more than 5 seconds means Vivo's security care feature is blocking the app installtion until explicit consent from the user is made, so in this case use uiautomator to clear the agreement and then continue. ## Architecture Five Gradle modules. The split is driven by one hard rule: the protocol implementation must be JVM-testable, so anything that imports `android.*` lives outside `:core-protocol`. ``` :core-protocol pure Kotlin/JVM — wire framing, UKEY2, SecureMessage, payload reassembly, sharing FSM, Inbound/OutboundConnection orchestrators. NO android.* imports. Adding one is a regression — guard in review. :core-protocol-test KAT vectors and shared fixtures. Pure JVM. :discovery-android mDNS publish/browse via Android NsdManager (migrated from JmDNS in #98 to fix vivo / Funtouch / OriginOS interop), network-change watcher. Phase 2 BLE: pulse scanner (BleQuickShareScanner, service UUID 0xFE2C), sender pulse advertiser (BleAdvertiser, 0xFE2C), and receiver-side fast-advertisement advertiser (BleQuickShareAdvertiser, 0xFEF3, #121) — all under `discovery.ble`. Android-only; wraps :core-protocol's EndpointInfo / BleServiceData byte encoders. :service-android Foreground receiver service (connectedDevice type), MediaStore-backed FileDestinationFactory, consent notification + broadcast receiver. :app UI: permissions onboarding, share-intent SendActivity, ShowQrActivity, consent trampoline. ``` `:core-protocol` enables `explicitApi()` and `allWarningsAsErrors`. Everything public must be intentional. ### Protocol layers (reading guide) The Quick Share stack is best read bottom-up: 1. **`...protocol.transport.FramedConnection`** — 4-byte big-endian length prefix over `java.net.Socket`, with `SANE_FRAME_LENGTH = 5 * 1024 * 1024`. `EndOfFrameStream` is the dedicated signal for a clean half-close at a frame boundary; mid-header truncation throws `EOFException`. 2. **`...protocol.crypto.Hkdf`** — RFC 5869 HKDF-SHA256, hand-rolled on `javax.crypto.Mac("HmacSHA256")`. Tink is intentionally NOT a dependency: its transitive `protobuf-java` clashes with `protobuf-javalite` on Android. 3. **`...protocol.ukey2.{Ukey2Client,Ukey2Server}`** — P256_SHA512 key-exchange handshake over `FramedConnection`. Computes `dhs = SHA256(magnitude(ECDH.x))`, exposes the raw serialized `Ukey2Message` bytes for downstream HKDF input. Includes explicit on-curve validation because SunEC's `KeyFactory.generatePublic` only range-checks. 4. **`...protocol.crypto.D2DKeyDerivation` + `D2DSessionKeys`** — derives `authString` (PIN material), `nextSecret`, the four AES-256 / HMAC-SHA256 traffic keys (client/server × encrypt/HMAC). All salt/info bytes are pinned by KAT vectors in `:core-protocol-test`. 5. **`...protocol.crypto.securemessage.{SecureMessageCodec,SecureChannel}`** — AES-256-CBC + HMAC-SHA256 envelope with **HMAC-verify-before-decrypt** order and **pre-incremented sequence numbers** (replay-protected). `SecureChannel` is the per-connection wrapper that reads/writes `OfflineFrame` protos. 6. **`...protocol.payload.{PayloadAssembler,PayloadTransferEncoder}`** — chunk reassembly for BYTES + FILE payloads, including the Android "two-frame BYTES" quirk (data chunk followed by a zero-body LAST_CHUNK). FILE bytes stream through a caller-supplied `FileDestinationFactory`. The encoder emits the same two-frame shape on send. 7. **`...protocol.sharing.{Inbound,Outbound}SharingFsm`** — pure FSMs that drive the Sharing.Nearby.Frame negotiation (PairedKeyEncryption, PairedKeyResult, Introduction, ConnectionResponse, Cancel). Inputs are events; outputs are an ordered `List`. No I/O. 8. **`...protocol.connection.{Inbound,Outbound}Connection`** — top-level orchestrators. Tie 1–7 together over a single `Socket`; expose `suspend fun run(...)`, `StateFlow<…ConnectionState>`, and a thread-safe `cancel()`. Receiver glue (consent, file destinations, transfer metadata) goes through here. `:service-android` wraps `OutboundConnection` (called by `SendActivity` after the user picks a peer) and runs `TcpReceiverServer` + `InboundConnection` from `ReceiverForegroundService` (foreground-service type `connectedDevice`). ### Quick Share interop quirks worth knowing These are baked into the codebase as comments at the relevant call sites; surfacing them here so future work doesn't relearn them the hard way: - **mDNS visibility bit is always 1 for stock Quick Share.** The "Everyone vs Contacts only" decision is enforced during the negotiation, not at the mDNS layer. The picker must not filter peers by `EndpointInfo.hidden`. - **Wi-Fi address resolution must use `ConnectivityManager.LinkProperties`**, not the deprecated `WifiManager.connectionInfo`. The latter returns sentinel `0.0.0.0` on API 31+ for apps without precise-location permission, and we don't ask for that. - **`ConnectionRequestFrame` minimum required fields**: `endpoint_id`, `endpoint_info`, `endpoint_name` (legacy, can be empty), `mediums = [WIFI_LAN]`, `keep_alive_interval_millis`, `keep_alive_timeout_millis`. Without `mediums`, Android 14+ Nearby Connections rejects the request. - **`ConnectionResponseFrame`** must have `response = ACCEPT`, `os_info.type = ANDROID` (`LINUX = 100` is g3-test-only and Samsung One UI silently FINs on it), `multiplex_socket_bitmask = 0` (Samsung One UI 8.0.5+ silently FINs without it; stock Google Nearby Connections always sets it; 0 = "no medium supports multiplex", which matches our single-Wi-Fi-LAN-socket implementation), and `safe_to_disconnect_version = 1` (Samsung One UI 7+ requires it). The deprecated legacy `status = 0` is also set for older receivers. - **ConnectionResponse exchange order is send-first-then-receive** (matches NearDrop's `OutboundNearbyConnection`). Receive-first deadlocks both peers until the receiver times out. - **FILE payloads require a dedicated empty `LAST_CHUNK` terminator** (same two-frame shape as BYTES). Samsung One UI 7+ silently discards FILE payloads whose final data chunk fuses the body with the `LAST_CHUNK` flag — the receiver decodes the SecureMessage successfully, the safe-disconnect handshake completes, but the file is never written to disk and the UI shows "couldn't receive file". Split the terminator into its own zero-byte frame (`flags=LAST_CHUNK, offset=totalSize, body=empty`). See `PayloadTransferEncoder.encodeFilePayload`. - **Outbound `DisconnectionFrame` must set `request_safe_to_disconnect = true`** because we advertise `safe_to_disconnect_version = 1` in `ConnectionResponseFrame`. Samsung One UI 7+ enforces the contract: a raw TCP FIN before the receiver's read pipeline drains marks every in-flight payload failed. After sending the Disconnection, drain the wire for up to 1500 ms waiting for the peer's `ack_safe_to_disconnect=true` (or peer FIN) before closing the socket. Gate the drain on terminals where we sent the request (Completed, Rejected, Cancelled-LOCAL); skip on Cancelled-PEER and Failed paths. See `OutboundConnectionDriver.runReceiveLoop` / `shouldDrainForSafeDisconnect`. - **`IntroductionFrame` must set `use_case = NEARBY_SHARE` and every `FileMetadata` must have a non-zero `id`** matching its `payload_id`. Samsung One UI 8.0.5 keys its receive-side bookkeeping on `FileMetadata.id`; leaving it at the proto default (0) silently discards the attachment with only a `NULL_MESSAGE` log at the medium layer. - **Blocking Socket I/O under `withContext(Dispatchers.IO)` does NOT honour coroutine cancellation while parked in a syscall.** `runReceiveLoop` in both connection drivers (and `TcpReceiverServer.stop`) closes the socket BEFORE `cancelAndJoin`'ing the pump, otherwise teardown deadlocks. ### Tests JVM tests under `:core-protocol/src/test/...` are the safety net for the entire wire stack — they cover bit-level KATs (HKDF / HMAC / AES-CBC / AES-GCM / ECDH P-256), sequence-number invariants on the SecureMessage envelope, and end-to-end loopback integration that pairs `OutboundConnection` with `InboundConnection` over real loopback `Socket` pairs (with `@Timeout(SEPARATE_THREAD)` so a hung integration fails the run instead of the whole JVM). Manual on-device interop is documented as Markdown checklists under `docs/testing/`: - `docs/testing/interop-neardrop-macos.md` — reference implementation on Mac. - `docs/testing/interop-stock-quick-share-android.md` — Pixel + Samsung coverage. ### Pre-merge debug-loop regression As the final test step before merging any PR that changes discovery, advertised device names, BLE initial-control, mDNS gating, Wi-Fi Direct upgrade, safe-disconnect, or payload transfer behavior, run the real-device debug loop against one vanilla GMS Android device and one non-GMS Android device. Do not merge until every scenario below passes 5 consecutive times in both directions. Matrix factors: - Topology: same Wi-Fi, and different/no shared Wi-Fi. If two distinct APs cannot be controlled reliably through ADB, use a no-shared-LAN setup such as GMS device Wi-Fi off with mobile data on while the non-GMS device stays on Wi-Fi. - File size: small file and large file. - File count: single file and multiple files. - Bada receiver name: default Android device name and customized advertised Bada device name. - Direction: Bada/non-GMS to GMS Quick Share, and GMS Quick Share to Bada/non-GMS. Acceptance criteria: - Every matrix cell completes 5 consecutive successful transfers with no retries hidden between attempts. - The receiver stores the expected number of files, with exact byte sizes for each payload and no stale files counted from earlier attempts. - The customized Bada name is visible and selectable from the GMS sender before GMS-to-Bada transfers. - Both same-Wi-Fi LAN paths and no-shared-LAN BLE/Wi-Fi Direct paths are exercised. - On failure, capture the relevant logcat tags from the On-device debugging section and, for vivo/Funtouch devices, also pull `getExternalFilesDir(null)/libredrop-outbound.log` before changing code. ## Conventions - **Branch names**: `/` where type is `feature`, `bugfix`, `hotfix`, `refactor`, `docs`, `test`, or `chore`. Issue-scoped branches include the issue number, e.g. `bugfix/issue-83-discovery-real-devices`. - **Commit / PR messages**: English only. No AI attribution lines (no `Co-Authored-By: Claude`, no `Generated with` blurbs). Use the `feat:` / `fix:` / `chore:` / `refactor:` / `docs:` / `test:` prefix that matches the change. - **Release process**: when preparing a release, make the Git release tag version match the app version defined in Gradle. In this repo, the source of truth is `app/build.gradle.kts` `defaultConfig.versionName`; if you bump the release tag to `vX.Y.Z`, update `versionName` to the same `X.Y.Z` variant string in the same change and verify they stay aligned. - **Merge strategy**: squash-merge (`gh pr merge --squash --delete-branch`) is the default. The `--delete-branch` flag will fail to delete the local branch if a worktree is using it; clean up the worktree afterwards with `git worktree remove --force `, then `git branch -D `. - **Worktrees**: parallel work uses sibling worktrees at `../wt-epic--issue-` or `../wt-issue-`. Always copy `local.properties` from the primary tree (it's gitignored): `cp /Users/kyujin/Projects/LibreDrop/local.properties .` — Android builds need it for `sdk.dir`.