This file provides guidance to AI Agents like Claude Code (claude.ai/code) or OpenAI Codex when working with code in this repository.
Standalone Android port of Google's Quick Share / Nearby Share protocol, modelled on 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 https://github.com/grishka/NearDrop/blob/master/PROTOCOL.md. 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.
# 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 ktlintFormatCI (.github/workflows/ci.yml) runs staticAnalysis, :core-protocol:test, :app:testDebugUnitTest, and :app:assembleDebug on JDK 17.
Several diagnostic logcat tags are wired up for real-device testing — useful when discovery or transfer behaves differently from JVM tests:
adb logcat -s LibreDropDiscovery LibreDropSend LibreDropOutbound LibreDropBleScan LibreDropBleAdv LibreDropMdnsGateIf 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:
adb shell cat /sdcard/Android/data/dev.bluehouse.libredrop.debug/files/libredrop-outbound.logDebug 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.
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.
The Quick Share stack is best read bottom-up:
...protocol.transport.FramedConnection— 4-byte big-endian length prefix overjava.net.Socket, withSANE_FRAME_LENGTH = 5 * 1024 * 1024.EndOfFrameStreamis the dedicated signal for a clean half-close at a frame boundary; mid-header truncation throwsEOFException....protocol.crypto.Hkdf— RFC 5869 HKDF-SHA256, hand-rolled onjavax.crypto.Mac("HmacSHA256"). Tink is intentionally NOT a dependency: its transitiveprotobuf-javaclashes withprotobuf-javaliteon Android....protocol.ukey2.{Ukey2Client,Ukey2Server}— P256_SHA512 key-exchange handshake overFramedConnection. Computesdhs = SHA256(magnitude(ECDH.x)), exposes the raw serializedUkey2Messagebytes for downstream HKDF input. Includes explicit on-curve validation because SunEC'sKeyFactory.generatePubliconly range-checks....protocol.crypto.D2DKeyDerivation+D2DSessionKeys— derivesauthString(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....protocol.crypto.securemessage.{SecureMessageCodec,SecureChannel}— AES-256-CBC + HMAC-SHA256 envelope with HMAC-verify-before-decrypt order and pre-incremented sequence numbers (replay-protected).SecureChannelis the per-connection wrapper that reads/writesOfflineFrameprotos....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-suppliedFileDestinationFactory. The encoder emits the same two-frame shape on send....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 orderedList<SharingFsmEffect>. No I/O....protocol.connection.{Inbound,Outbound}Connection— top-level orchestrators. Tie 1–7 together over a singleSocket; exposesuspend fun run(...),StateFlow<…ConnectionState>, and a thread-safecancel(). 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).
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 deprecatedWifiManager.connectionInfo. The latter returns sentinel0.0.0.0on API 31+ for apps without precise-location permission, and we don't ask for that. ConnectionRequestFrameminimum required fields:endpoint_id,endpoint_info,endpoint_name(legacy, can be empty),mediums = [WIFI_LAN],keep_alive_interval_millis,keep_alive_timeout_millis. Withoutmediums, Android 14+ Nearby Connections rejects the request.ConnectionResponseFramemust haveresponse = ACCEPT,os_info.type = ANDROID(LINUX = 100is 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), andsafe_to_disconnect_version = 1(Samsung One UI 7+ requires it). The deprecated legacystatus = 0is 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_CHUNKterminator (same two-frame shape as BYTES). Samsung One UI 7+ silently discards FILE payloads whose final data chunk fuses the body with theLAST_CHUNKflag — 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). SeePayloadTransferEncoder.encodeFilePayload. - Outbound
DisconnectionFramemust setrequest_safe_to_disconnect = truebecause we advertisesafe_to_disconnect_version = 1inConnectionResponseFrame. 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'sack_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. SeeOutboundConnectionDriver.runReceiveLoop/shouldDrainForSafeDisconnect. IntroductionFramemust setuse_case = NEARBY_SHAREand everyFileMetadatamust have a non-zeroidmatching itspayload_id. Samsung One UI 8.0.5 keys its receive-side bookkeeping onFileMetadata.id; leaving it at the proto default (0) silently discards the attachment with only aNULL_MESSAGElog at the medium layer.- Blocking Socket I/O under
withContext(Dispatchers.IO)does NOT honour coroutine cancellation while parked in a syscall.runReceiveLoopin both connection drivers (andTcpReceiverServer.stop) closes the socket BEFOREcancelAndJoin'ing the pump, otherwise teardown deadlocks.
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.
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.logbefore changing code.
- Branch names:
<type>/<short-description>where type isfeature,bugfix,hotfix,refactor,docs,test, orchore. 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, noGenerated withblurbs). Use thefeat:/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.ktsdefaultConfig.versionName; if you bump the release tag tovX.Y.Z, updateversionNameto the sameX.Y.Zvariant string in the same change and verify they stay aligned. - Merge strategy: squash-merge (
gh pr merge <N> --squash --delete-branch) is the default. The--delete-branchflag will fail to delete the local branch if a worktree is using it; clean up the worktree afterwards withgit worktree remove --force <path>, thengit branch -D <branch>. - Worktrees: parallel work uses sibling worktrees at
../wt-epic-<E>-issue-<N>or../wt-issue-<N>. Always copylocal.propertiesfrom the primary tree (it's gitignored):cp /Users/kyujin/Projects/LibreDrop/local.properties .— Android builds need it forsdk.dir.