423c314f718a
remove chirp audio pairing, its screen and the mic permission, keeping the transport interface and sync layer
- time
- 2026-07-25 00:10 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- retire chirp pairing
- parent
- 0e74837151f3
↓ peach-android-423c314f.tar.gzatlas at this save
files changed15 files
app/src/main/AndroidManifest.xml+0 −1app/src/main/java/com/peachpasswords/sync/AndroidAudioIO.kt+0 −102app/src/main/java/com/peachpasswords/sync/ChirpPairing.kt+0 −671app/src/main/java/com/peachpasswords/sync/ChirpPayload.kt+0 −109app/src/main/java/com/peachpasswords/sync/Ggwave.kt+0 −41app/src/main/java/com/peachpasswords/sync/PairingFrames.kt+14 −0app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+0 −2app/src/main/java/com/peachpasswords/ui/PeachNav.kt+0 −1app/src/main/java/com/peachpasswords/ui/lock/LockScreen.kt+0 −1app/src/main/java/com/peachpasswords/ui/pairing/ChirpPairScreen.kt+0 −242app/src/main/java/com/peachpasswords/ui/pairing/ChirpPairViewModel.kt+0 −187app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+22 −0app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+1 −1app/src/test/java/com/peachpasswords/sync/ChirpPairingTest.kt+0 −256app/src/test/java/com/peachpasswords/sync/ChirpSyncTransportTest.kt+5 −5app/src/main/AndroidManifest.xml+0 −1
@@ -8,7 +8,6 @@88 <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />99 <uses-permission android:name="android.permission.VIBRATE" />1010 <uses-permission android:name="android.permission.USE_BIOMETRIC" />11- <uses-permission android:name="android.permission.RECORD_AUDIO" />1211 <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />1312 <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />1413 <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />app/src/main/java/com/peachpasswords/sync/AndroidAudioIO.kt+0 −102
@@ -1,102 +0,0 @@1-package com.peachpasswords.sync2-3-import android.annotation.SuppressLint4-import android.media.AudioAttributes5-import android.media.AudioFormat6-import android.media.AudioManager7-import android.media.AudioRecord8-import android.media.AudioTrack9-import android.media.MediaRecorder10-import kotlinx.coroutines.Dispatchers11-import kotlinx.coroutines.withContext12-13-/**14- * Real Android audio transport for Chirp pairing — the platform half of the15- * ggwave bridge. Speaker out via [AudioTrack] and mic in via [AudioRecord],16- * both float PCM at 48 kHz mono (ggwave's native rate). The codec that turns17- * payload bytes into waveforms is injected separately (see [ChirpCodecFactory]);18- * the native ggwave binding is the remaining NDK step, but the audio I/O this19- * file provides is production-real.20- *21- * Requires RECORD_AUDIO; [openPcmFloatAudioIO] raises a typed22- * [ChirpExchangeError] when the mic can't initialise (permission denied or23- * device busy) so the pairing flow surfaces MIC_DENIED rather than crashing.24- */25-private const val SAMPLE_RATE = 48_00026-27-@SuppressLint("MissingPermission") // Caller gates on RECORD_AUDIO; failure → MIC_DENIED below.28-suspend fun openPcmFloatAudioIO(): AudioIO = withContext(Dispatchers.IO) {29- val minIn = AudioRecord.getMinBufferSize(30- SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_FLOAT,31- )32- if (minIn <= 0) {33- throw ChirpExchangeError(ChirpExchangeErrorKind.AUDIO_UNAVAILABLE, "mic buffer size unavailable")34- }35- val recordBuffer = maxOf(minIn, SAMPLE_RATE / 4 * 4)36- val record = try {37- AudioRecord(38- MediaRecorder.AudioSource.MIC,39- SAMPLE_RATE,40- AudioFormat.CHANNEL_IN_MONO,41- AudioFormat.ENCODING_PCM_FLOAT,42- recordBuffer,43- )44- } catch (e: Exception) {45- throw ChirpExchangeError(ChirpExchangeErrorKind.MIC_DENIED, e.message ?: "mic init failed")46- }47- if (record.state != AudioRecord.STATE_INITIALIZED) {48- record.release()49- throw ChirpExchangeError(ChirpExchangeErrorKind.MIC_DENIED, "mic failed to initialise")50- }51-52- val minOut = AudioTrack.getMinBufferSize(53- SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_FLOAT,54- )55- val track = AudioTrack.Builder()56- .setAudioAttributes(57- AudioAttributes.Builder()58- .setUsage(AudioAttributes.USAGE_MEDIA)59- .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)60- .build(),61- )62- .setAudioFormat(63- AudioFormat.Builder()64- .setSampleRate(SAMPLE_RATE)65- .setEncoding(AudioFormat.ENCODING_PCM_FLOAT)66- .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)67- .build(),68- )69- .setBufferSizeInBytes(maxOf(minOut, 4096))70- .setTransferMode(AudioTrack.MODE_STREAM)71- .build()72-73- record.startRecording()74-75- object : AudioIO {76- override val sampleRate = SAMPLE_RATE77- private val readBuf = FloatArray(recordBuffer / 4)78-79- override suspend fun play(waveform: FloatArray) = withContext(Dispatchers.IO) {80- if (track.playState != AudioTrack.PLAYSTATE_PLAYING) track.play()81- var offset = 082- while (offset < waveform.size) {83- val n = track.write(waveform, offset, waveform.size - offset, AudioTrack.WRITE_BLOCKING)84- if (n < 0) break85- offset += n86- }87- }88-89- override suspend fun pullSamples(): FloatArray = withContext(Dispatchers.IO) {90- val n = record.read(readBuf, 0, readBuf.size, AudioRecord.READ_BLOCKING)91- if (n <= 0) FloatArray(0) else readBuf.copyOf(n)92- }93-94- override suspend fun close(): Unit = withContext(Dispatchers.IO) {95- runCatching { record.stop() }96- runCatching { record.release() }97- runCatching { track.stop() }98- runCatching { track.release() }99- Unit100- }101- }102-}app/src/main/java/com/peachpasswords/sync/ChirpPairing.kt+0 −671
@@ -1,671 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.async4-import kotlinx.coroutines.coroutineScope5-import kotlinx.coroutines.cancelAndJoin6-import kotlinx.coroutines.delay7-import kotlinx.coroutines.isActive8-import kotlinx.coroutines.withTimeoutOrNull9-import java.security.SecureRandom10-import java.util.concurrent.atomic.AtomicBoolean11-import org.bouncycastle.crypto.params.X25519PrivateKeyParameters12-13-const val CHIRP_PAIRING_PORT: Int = 4914214-15-interface PairingTransport {16- suspend fun send(msg: ByteArray)17- suspend fun receive(): ByteArray18- suspend fun close()19-}20-21-data class PairingEndpoint(22- val ip: String,23- val port: Int,24-)25-26-data class PairingServer(27- val transport: PairingTransport,28- val peerIp: String,29-)30-31-data class PairingHandshakeResult(32- val device: PairedDevice,33- val sas: String,34- /**35- * The peer's signed Pro license blob, if it advertised one. Activated36- * (signature-verified) ONLY after the SAS is confirmed. Never persisted.37- */38- val peerLicense: String? = null,39-)40-41-enum class PairingRole {42- INITIATOR,43- RESPONDER,44-}45-46-interface ChirpPairingDeps {47- suspend fun fetchLocalIdentity(): LocalChirpIdentity48- suspend fun completePairing(paired: PairedDevice)49- suspend fun confirmSAS(sas: String): Boolean50- suspend fun cacheLastKnownIp(pubkeyHex: String, ip: String)51- suspend fun getLocalSubnet(): String?52- suspend fun openPairingTransport(endpoint: PairingEndpoint): PairingTransport53- suspend fun openPairingServer(): PairingServer54- suspend fun openAudio(): AudioIO55- val chirpTimeoutMs: Long56- fun onProgress(progress: PairingProgress)57-58- /**59- * Activate a Pro license the (now SAS-confirmed) peer advertised. The60- * implementation verifies the signature and ignores it if already Pro.61- * Default no-op so existing callers/tests need no change.62- */63- suspend fun activatePeerLicense(license: String) {}64-}65-66-class ChirpPairingFailure(67- val phase: PairingPhase,68- message: String,69-) : Error(message)70-71-interface AudioIO {72- val sampleRate: Int73- suspend fun play(waveform: FloatArray)74- suspend fun pullSamples(): FloatArray75- suspend fun close()76-}77-78-interface PairingKeyPair {79- val publicKey: ByteArray80- val secretKey: ByteArray81-}82-83-private data class PairingX25519KeyPair(84- override val publicKey: ByteArray,85- override val secretKey: ByteArray,86-) : PairingKeyPair87-88-data class PairingAttempt(89- val ephemeralPrivateKey: ByteArray,90- val ephemeralPublicKey: ByteArray,91- val sessionNonce: ByteArray,92- val outgoingPayload: ChirpPayload,93-)94-95-fun createPairingAttempt(deviceType: Byte): PairingAttempt {96- val kp = generatePairingKeyPair()97- val nonce = ByteArray(16)98- secureRandom().nextBytes(nonce)99- return PairingAttempt(100- ephemeralPrivateKey = kp.secretKey,101- ephemeralPublicKey = kp.publicKey,102- sessionNonce = nonce,103- outgoingPayload = ChirpPayload(104- version = CHIRP_PROTOCOL_VERSION,105- deviceType = deviceType,106- ephemeralPublicKey = kp.publicKey,107- sessionNonce = nonce,108- ),109- )110-}111-112-data class ChirpExchangeResult(113- val remoteChirp: ChirpPayload,114-)115-116-data class ChirpExchangeOptions(117- val attempt: PairingAttempt,118- val timeoutMs: Long = DEFAULT_CHIRP_TIMEOUT_MS,119- val onDiscard: ((ChirpDecodeError.Kind) -> Unit)? = null,120- val onChirpPlayed: ((Int) -> Unit)? = null,121-)122-123-enum class ChirpExchangeErrorKind {124- TIMEOUT,125- VERSION_MISMATCH,126- MIC_DENIED,127- AUDIO_UNAVAILABLE,128-}129-130-class ChirpExchangeError(131- val kind: ChirpExchangeErrorKind,132- message: String? = null,133-) : Error(message ?: kind.name.lowercase())134-135-private const val CHIRP_LOOP_INTERVAL_MS = 4_000L136-private const val DEFAULT_CHIRP_TIMEOUT_MS = 90_000L137-private const val LISTEN_FRAME_SAMPLES = 4096138-139-fun generatePairingKeyPair(): PairingKeyPair {140- val priv = X25519PrivateKeyParameters(secureRandom())141- return PairingX25519KeyPair(142- publicKey = priv.generatePublicKey().encoded,143- secretKey = priv.encoded,144- )145-}146-147-private fun secureRandom(): SecureRandom = SecureRandom()148-149-private fun sameBytes(a: ByteArray, b: ByteArray): Boolean {150- if (a.size != b.size) return false151- for (i in a.indices) {152- if (a[i] != b[i]) return false153- }154- return true155-}156-157-/**158- * Run the two-chirp exchange against a provided audio interface.159- *160- * This is a direct port of the browser coordinator logic. The codec and161- * audio bridge are injectable so the protocol can be unit-tested before the162- * Android-native ggwave bridge lands.163- */164-suspend fun runChirpExchange(165- io: AudioIO,166- opts: ChirpExchangeOptions,167- codecFactory: ChirpCodecFactory = ::createChirpCodec,168-): ChirpExchangeResult {169- return coroutineScope {170- val codec = codecFactory(171- ChirpCodecOptions(172- sampleRate = io.sampleRate,173- protocol = GgwaveProtocol.ULTRASOUND_FASTEST,174- volumePercent = 10,175- ),176- )177- val ourBytes = encodeChirpPayload(opts.attempt.outgoingPayload)178- val ourWaveform = codec.encode(ourBytes)179-180- val deadline = System.currentTimeMillis() + opts.timeoutMs181- val stopped = AtomicBoolean(false)182-183- val playLoop = async {184- var playCount = 0185- while (isActive && !stopped.get() && System.currentTimeMillis() < deadline) {186- try {187- io.play(ourWaveform)188- playCount++189- opts.onChirpPlayed?.invoke(playCount)190- } catch (_: Throwable) {191- // Play failures are non-fatal; keep listening.192- }193- if (stopped.get()) break194- val remaining = deadline - System.currentTimeMillis()195- val wait = minOf(CHIRP_LOOP_INTERVAL_MS, maxOf(0L, remaining))196- if (wait <= 0L) break197- delay(wait)198- }199- }200-201- try {202- while (System.currentTimeMillis() < deadline && !stopped.get()) {203- val samples = io.pullSamples()204- if (samples.isEmpty()) {205- delay(50)206- continue207- }208-209- val decoded = codec.decode(samples)210- if (decoded == null) continue211- if (decoded.size != CHIRP_PAYLOAD_LENGTH) {212- opts.onDiscard?.invoke(ChirpDecodeError.Kind.LENGTH)213- continue214- }215-216- try {217- val payload = decodeChirpPayload(decoded)218- if (sameBytes(payload.ephemeralPublicKey, opts.attempt.ephemeralPublicKey)) {219- continue220- }221- if (payload.version != CHIRP_PROTOCOL_VERSION) {222- throw ChirpExchangeError(ChirpExchangeErrorKind.VERSION_MISMATCH)223- }224- stopped.set(true)225- return@coroutineScope ChirpExchangeResult(remoteChirp = payload)226- } catch (err: ChirpExchangeError) {227- throw err228- } catch (err: ChirpDecodeError) {229- if (err.kind == ChirpDecodeError.Kind.VERSION) {230- throw ChirpExchangeError(ChirpExchangeErrorKind.VERSION_MISMATCH)231- }232- opts.onDiscard?.invoke(err.kind)233- continue234- }235- }236- throw ChirpExchangeError(ChirpExchangeErrorKind.TIMEOUT)237- } finally {238- stopped.set(true)239- codec.free()240- playLoop.cancelAndJoin()241- try {242- io.close()243- } catch (_: Throwable) {244- // ignore245- }246- }247- }248-}249-250-suspend fun openAndroidAudioIO(): AudioIO = openPcmFloatAudioIO()251-252-suspend fun runChirpPairing(253- deps: ChirpPairingDeps,254- role: PairingRole = PairingRole.RESPONDER,255- codecFactory: ChirpCodecFactory = ::createChirpCodec,256-): PairedDevice {257- deps.onProgress(PairingProgress(phase = PairingPhase.IDLE))258-259- val localIdentity = deps.fetchLocalIdentity()260-261- if (role == PairingRole.RESPONDER && localIdentity.deviceType != DeviceType.EXTENSION) {262- throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, "responder requires extension identity")263- }264- if (role == PairingRole.INITIATOR && localIdentity.deviceType == DeviceType.EXTENSION) {265- throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, "initiator requires non-extension identity")266- }267-268- deps.onProgress(PairingProgress(phase = PairingPhase.LISTENING))269-270- val deviceTypeByte = when (localIdentity.deviceType) {271- DeviceType.ANDROID -> deviceTypeToByte(DeviceType.ANDROID)272- DeviceType.IOS -> deviceTypeToByte(DeviceType.IOS)273- DeviceType.DESKTOP -> deviceTypeToByte(DeviceType.DESKTOP)274- DeviceType.EXTENSION -> deviceTypeToByte(DeviceType.EXTENSION)275- }276- val attempt = createPairingAttempt(deviceTypeByte)277-278- val remoteChirpPublicKey: ByteArray279- try {280- val audio = deps.openAudio()281- val result = runChirpExchange(282- io = audio,283- opts = ChirpExchangeOptions(284- attempt = attempt,285- timeoutMs = deps.chirpTimeoutMs,286- ),287- codecFactory = codecFactory,288- )289- remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey290- } catch (err: ChirpExchangeError) {291- val phase = when (err.kind) {292- ChirpExchangeErrorKind.TIMEOUT -> PairingPhase.TIMEOUT293- ChirpExchangeErrorKind.VERSION_MISMATCH -> PairingPhase.VERSION_MISMATCH294- ChirpExchangeErrorKind.MIC_DENIED -> PairingPhase.MIC_DENIED295- ChirpExchangeErrorKind.AUDIO_UNAVAILABLE -> PairingPhase.HANDSHAKE_FAILED296- }297- deps.onProgress(PairingProgress(phase = phase, errorMessage = err.message))298- throw ChirpPairingFailure(phase, err.message ?: phase.wireValue)299- }300-301- deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE))302-303- if (role == PairingRole.RESPONDER) {304- return runResponderHandshake(deps, attempt, localIdentity)305- }306- return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity)307-}308-309-private suspend fun runResponderHandshake(310- deps: ChirpPairingDeps,311- attempt: PairingAttempt,312- localIdentity: LocalChirpIdentity,313-): PairedDevice {314- val subnet = deps.getLocalSubnet()315- if (subnet.isNullOrBlank()) {316- deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = "no_subnet"))317- throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, "could not determine local subnet")318- }319-320- val peerEndpoint = firstResponsivePeer(deps, subnet, CHIRP_PAIRING_PORT)321- if (peerEndpoint == null) {322- deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = "peer_not_found"))323- throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, "could not reach paired peer on subnet")324- }325-326- val transport = deps.openPairingTransport(peerEndpoint)327- val result = try {328- runPairingResponder(329- ourEphemeralPrivateKey = attempt.ephemeralPrivateKey,330- ourEphemeralPublicKey = attempt.ephemeralPublicKey,331- ourIdentity = localIdentity,332- transport = transport,333- )334- } catch (err: Throwable) {335- try {336- transport.close()337- } catch (_: Throwable) {338- // ignore339- }340- deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = err.message))341- throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, err.message ?: "handshake failed")342- }343- transport.close()344-345- val paired = gateOnSAS(deps, result)346- activatePeerLicenseIfAny(deps, result)347- deps.completePairing(paired)348- deps.cacheLastKnownIp(paired.longTermPublicKeyHex.orEmpty(), peerEndpoint.ip)349- deps.onProgress(PairingProgress(phase = PairingPhase.PAIRED, peerDeviceName = paired.deviceName))350- return paired351-}352-353-private suspend fun gateOnSAS(354- deps: ChirpPairingDeps,355- result: PairingHandshakeResult,356-): PairedDevice {357- val confirmed = try {358- deps.confirmSAS(result.sas)359- } catch (err: Throwable) {360- deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = err.message))361- throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, err.message ?: "handshake failed")362- }363- if (!confirmed) {364- deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = "sas_rejected"))365- throw ChirpPairingFailure(366- PairingPhase.HANDSHAKE_FAILED,367- "pairing rejected - the verification codes did not match",368- )369- }370- return result.device371-}372-373-/**374- * If the (now SAS-confirmed) peer advertised a Pro license, hand it to the375- * deps to verify + activate. Best-effort: a bad/duplicate license never fails376- * the pairing — the device link still succeeds.377- */378-private suspend fun activatePeerLicenseIfAny(379- deps: ChirpPairingDeps,380- result: PairingHandshakeResult,381-) {382- val license = result.peerLicense ?: return383- try {384- deps.activatePeerLicense(license)385- } catch (_: Throwable) {386- // Invalid signature, already-Pro, or storage error — pairing stands.387- }388-}389-390-private suspend fun runInitiatorHandshake(391- deps: ChirpPairingDeps,392- remoteChirpPublicKey: ByteArray,393- localIdentity: LocalChirpIdentity,394-): PairedDevice {395- val server = try {396- deps.openPairingServer()397- } catch (err: Throwable) {398- deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = err.message))399- throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, err.message ?: "handshake failed")400- }401-402- val result = try {403- runPairingInitiator(remoteChirpPublicKey, localIdentity, server.transport)404- } catch (err: Throwable) {405- try {406- server.transport.close()407- } catch (_: Throwable) {408- // ignore409- }410- deps.onProgress(PairingProgress(phase = PairingPhase.HANDSHAKE_FAILED, errorMessage = err.message))411- throw ChirpPairingFailure(PairingPhase.HANDSHAKE_FAILED, err.message ?: "handshake failed")412- }413- server.transport.close()414-415- val paired = gateOnSAS(deps, result)416- activatePeerLicenseIfAny(deps, result)417- deps.completePairing(paired)418- deps.cacheLastKnownIp(paired.longTermPublicKeyHex.orEmpty(), server.peerIp)419- deps.onProgress(PairingProgress(phase = PairingPhase.PAIRED, peerDeviceName = paired.deviceName))420- return paired421-}422-423-private suspend fun firstResponsivePeer(424- deps: ChirpPairingDeps,425- subnet: String,426- port: Int,427-): PairingEndpoint? {428- val probeTimeoutMs = 250L429- val batch = 16430- for (start in 1..254 step batch) {431- val end = minOf(start + batch - 1, 254)432- val ips = (start..end).map { "$subnet.$it" }433- val results = coroutineScope {434- ips.map { ip ->435- async {436- try {437- val transport = withTimeoutOrNull(probeTimeoutMs) {438- deps.openPairingTransport(PairingEndpoint(ip = ip, port = port))439- }440- if (transport != null) {441- transport.close()442- PairingEndpoint(ip = ip, port = port)443- } else {444- null445- }446- } catch (_: Throwable) {447- null448- }449- }450- }.mapNotNull { it.await() }451- }452- val hit = results.firstOrNull()453- if (hit != null) return hit454- }455- return null456-}457-458-private suspend fun runPairingInitiator(459- remoteEphemeralPublicKey: ByteArray,460- ourIdentity: LocalChirpIdentity,461- transport: PairingTransport,462-): PairingHandshakeResult {463- val session = noiseNKInitiator(464- remoteEphemeralPublicKey,465- transport::send,466- transport::receive,467- )468-469- transport.send(470- session.encrypt(471- encodePairingFrame(472- PairingFrame(473- type = "PAIR_IDENTITY",474- deviceId = ourIdentity.deviceId,475- deviceName = ourIdentity.deviceName,476- deviceType = ourIdentity.deviceType,477- longTermPublicKeyHex = ourIdentity.longTermPublicKeyHex,478- signingPublicKeyHex = ourIdentity.signingPublicKeyHex,479- wsPort = ourIdentity.wsPort,480- license = ourIdentity.license,481- ),482- ),483- ),484- )485-486- val theirCipher = transport.receive()487- val theirPlain = session.decrypt(theirCipher)488- val theirFrame = decodePairingFrame(theirPlain)489- return PairingHandshakeResult(490- device = frameToPairedDevice(theirFrame),491- sas = SASDerivation.derive(session.handshakeHash),492- peerLicense = theirFrame.license,493- )494-}495-496-private suspend fun runPairingResponder(497- ourEphemeralPrivateKey: ByteArray,498- ourEphemeralPublicKey: ByteArray,499- ourIdentity: LocalChirpIdentity,500- transport: PairingTransport,501-): PairingHandshakeResult {502- val session = noiseNKResponder(503- ourEphemeralPrivateKey,504- ourEphemeralPublicKey,505- transport::send,506- transport::receive,507- )508-509- val theirCipher = transport.receive()510- val theirPlain = session.decrypt(theirCipher)511- val theirFrame = decodePairingFrame(theirPlain)512-513- transport.send(514- session.encrypt(515- encodePairingFrame(516- PairingFrame(517- type = "PAIR_IDENTITY",518- deviceId = ourIdentity.deviceId,519- deviceName = ourIdentity.deviceName,520- deviceType = ourIdentity.deviceType,521- longTermPublicKeyHex = ourIdentity.longTermPublicKeyHex,522- signingPublicKeyHex = ourIdentity.signingPublicKeyHex,523- wsPort = ourIdentity.wsPort,524- license = ourIdentity.license,525- ),526- ),527- ),528- )529-530- return PairingHandshakeResult(531- device = frameToPairedDevice(theirFrame),532- sas = SASDerivation.derive(session.handshakeHash),533- peerLicense = theirFrame.license,534- )535-}536-537-internal data class PairingFrame(538- val type: String,539- val deviceId: String,540- val deviceName: String,541- val deviceType: DeviceType,542- val longTermPublicKeyHex: String,543- val signingPublicKeyHex: String? = null,544- val wsPort: Int? = null,545- // Optional signed Pro license blob — additive; peers that don't understand546- // it ignore it. Activated by the receiver only after SAS confirmation.547- val license: String? = null,548-)549-550-internal fun encodePairingFrame(frame: PairingFrame): ByteArray {551- val builder = StringBuilder()552- builder.append('{')553- builder.append("\"type\":\"").append(frame.type).append('\"')554- builder.append(",\"device_id\":\"").append(escapeJson(frame.deviceId)).append('\"')555- builder.append(",\"device_name\":\"").append(escapeJson(frame.deviceName)).append('\"')556- builder.append(",\"device_type\":\"").append(frame.deviceType.wireValue).append('\"')557- builder.append(",\"long_term_public_key_hex\":\"").append(frame.longTermPublicKeyHex).append('\"')558- if (!frame.signingPublicKeyHex.isNullOrBlank()) {559- builder.append(",\"signing_public_key_hex\":\"").append(frame.signingPublicKeyHex).append('\"')560- }561- if (frame.wsPort != null) {562- builder.append(",\"ws_port\":").append(frame.wsPort)563- }564- if (!frame.license.isNullOrBlank()) {565- builder.append(",\"license\":\"").append(escapeJson(frame.license)).append('\"')566- }567- builder.append('}')568- return builder.toString().encodeToByteArray()569-}570-571-internal fun decodePairingFrame(bytes: ByteArray): PairingFrame {572- val text = bytes.decodeToString()573- val parsed = simpleJsonObject(text)574- require(parsed["type"] == "PAIR_IDENTITY") { "expected PAIR_IDENTITY frame" }575- val deviceId = parsed["device_id"] ?: error("device_id missing")576- val deviceName = parsed["device_name"] ?: error("device_name missing")577- val deviceType = when (parsed["device_type"]) {578- "extension" -> DeviceType.EXTENSION579- "android" -> DeviceType.ANDROID580- "ios" -> DeviceType.IOS581- "desktop" -> DeviceType.DESKTOP582- else -> error("device_type missing")583- }584- val longTermPublicKeyHex = parsed["long_term_public_key_hex"] ?: error("long_term_public_key_hex missing")585- require(longTermPublicKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {586- "long_term_public_key_hex is not 32-byte hex"587- }588- val signingPublicKeyHex = parsed["signing_public_key_hex"]?.takeIf { it.isNotBlank() }589- if (signingPublicKeyHex != null) {590- require(signingPublicKeyHex.matches(Regex("^[0-9a-f]{64}$"))) {591- "signing_public_key_hex is not 32-byte hex"592- }593- }594- val wsPort = parsed["ws_port"]?.toIntOrNull()595- val license = parsed["license"]?.takeIf { it.isNotBlank() }596- return PairingFrame(597- type = "PAIR_IDENTITY",598- deviceId = deviceId,599- deviceName = deviceName,600- deviceType = deviceType,601- longTermPublicKeyHex = longTermPublicKeyHex,602- signingPublicKeyHex = signingPublicKeyHex,603- wsPort = wsPort,604- license = license,605- )606-}607-608-private fun frameToPairedDevice(frame: PairingFrame): PairedDevice {609- return PairedDevice(610- deviceId = frame.deviceId,611- deviceName = frame.deviceName,612- deviceType = frame.deviceType.wireValue,613- longTermPublicKeyHex = frame.longTermPublicKeyHex,614- signingPublicKeyHex = frame.signingPublicKeyHex,615- trustSource = "direct",616- wsPort = frame.wsPort,617- pairedAt = System.currentTimeMillis(),618- )619-}620-621-private fun escapeJson(value: String): String {622- return buildString(value.length) {623- for (ch in value) {624- when (ch) {625- '\\' -> append("\\\\")626- '"' -> append("\\\"")627- '\b' -> append("\\b")628- '\u000C' -> append("\\f")629- '\n' -> append("\\n")630- '\r' -> append("\\r")631- '\t' -> append("\\t")632- else -> append(ch)633- }634- }635- }636-}637-638-private fun simpleJsonObject(text: String): Map<String, String> {639- val trimmed = text.trim()640- require(trimmed.startsWith("{") && trimmed.endsWith("}")) { "expected JSON object" }641- val body = trimmed.substring(1, trimmed.length - 1).trim()642- if (body.isEmpty()) return emptyMap()643- val out = linkedMapOf<String, String>()644- var i = 0645- while (i < body.length) {646- while (i < body.length && body[i].isWhitespace()) i++647- require(i < body.length && body[i] == '"') { "expected string key" }648- i++649- val keyStart = i650- while (i < body.length && body[i] != '"') i++651- val key = body.substring(keyStart, i)652- i++653- while (i < body.length && (body[i].isWhitespace() || body[i] == ':')) i++654- val value: String655- if (i < body.length && body[i] == '"') {656- i++657- val valueStart = i658- while (i < body.length && body[i] != '"') i++659- value = body.substring(valueStart, i)660- i++661- } else {662- val valueStart = i663- while (i < body.length && body[i] != ',') i++664- value = body.substring(valueStart, i).trim()665- }666- out[key] = value667- while (i < body.length && body[i].isWhitespace()) i++668- if (i < body.length && body[i] == ',') i++669- }670- return out671-}app/src/main/java/com/peachpasswords/sync/ChirpPayload.kt+0 −109
@@ -1,109 +0,0 @@1-package com.peachpasswords.sync2-3-class ChirpDecodeError(kind: Kind, message: String) : Error(message) {4- enum class Kind {5- LENGTH,6- MAGIC,7- VERSION,8- DEVICE_TYPE,9- CHECKSUM,10- PADDING,11- }12-13- val kind: Kind = kind14-}15-16-data class ChirpPayload(17- val version: Int,18- val deviceType: Byte,19- val ephemeralPublicKey: ByteArray,20- val sessionNonce: ByteArray,21-)22-23-fun encodeChirpPayload(payload: ChirpPayload): ByteArray {24- require(payload.ephemeralPublicKey.size == 32) { "ephemeralPublicKey must be 32 bytes" }25- require(payload.sessionNonce.size == 16) { "sessionNonce must be 16 bytes" }26-27- val buf = ByteArray(CHIRP_PAYLOAD_LENGTH)28- CHIRP_MAGIC_BYTES.copyInto(buf, 0)29- buf[4] = (payload.version and 0xff).toByte()30- buf[5] = payload.deviceType31- payload.ephemeralPublicKey.copyInto(buf, 6)32- payload.sessionNonce.copyInto(buf, 38)33-34- val crc = crc32(buf.copyOfRange(0, 54))35- buf[54] = ((crc ushr 24) and 0xff).toByte()36- buf[55] = ((crc ushr 16) and 0xff).toByte()37- buf[56] = ((crc ushr 8) and 0xff).toByte()38- buf[57] = (crc and 0xff).toByte()39- return buf40-}41-42-fun decodeChirpPayload(buf: ByteArray): ChirpPayload {43- if (buf.size != CHIRP_PAYLOAD_LENGTH) {44- throw ChirpDecodeError(45- ChirpDecodeError.Kind.LENGTH,46- "chirp payload must be $CHIRP_PAYLOAD_LENGTH bytes, got ${buf.size}",47- )48- }49- for (i in 0 until 4) {50- if (buf[i] != CHIRP_MAGIC_BYTES[i]) {51- throw ChirpDecodeError(ChirpDecodeError.Kind.MAGIC, "magic bytes are not PEAC")52- }53- }54- val version = buf[4].toInt() and 0xff55- if (version != CHIRP_PROTOCOL_VERSION) {56- throw ChirpDecodeError(ChirpDecodeError.Kind.VERSION, "unsupported chirp protocol version $version")57- }58- val deviceType = buf[5]59- if ((deviceType.toInt() and 0xff) !in 0x01..0x04) {60- throw ChirpDecodeError(61- ChirpDecodeError.Kind.DEVICE_TYPE,62- "unknown device type byte 0x${(deviceType.toInt() and 0xff).toString(16)}",63- )64- }65- val declared =66- ((buf[54].toInt() and 0xff) shl 24) or67- ((buf[55].toInt() and 0xff) shl 16) or68- ((buf[56].toInt() and 0xff) shl 8) or69- (buf[57].toInt() and 0xff)70- val computed = crc32(buf.copyOfRange(0, 54))71- if (declared != computed) {72- throw ChirpDecodeError(ChirpDecodeError.Kind.CHECKSUM, "CRC32 mismatch")73- }74- for (i in 58 until CHIRP_PAYLOAD_LENGTH) {75- if (buf[i] != 0.toByte()) {76- throw ChirpDecodeError(ChirpDecodeError.Kind.PADDING, "reserved padding bytes must be zero")77- }78- }79- return ChirpPayload(80- version = version,81- deviceType = deviceType,82- ephemeralPublicKey = buf.copyOfRange(6, 38),83- sessionNonce = buf.copyOfRange(38, 54),84- )85-}86-87-private var crcTable: IntArray? = null88-89-private fun buildCrcTable(): IntArray {90- val table = IntArray(256)91- for (i in 0 until 256) {92- var c = i93- for (k in 0 until 8) {94- c = if ((c and 1) != 0) (0xedb88320.toInt() xor (c ushr 1)) else (c ushr 1)95- }96- table[i] = c97- }98- return table99-}100-101-fun crc32(bytes: ByteArray): Int {102- if (crcTable == null) crcTable = buildCrcTable()103- var crc = -1104- val table = crcTable!!105- for (b in bytes) {106- crc = table[(crc xor (b.toInt() and 0xff)) and 0xff] xor (crc ushr 8)107- }108- return crc xor -1109-}app/src/main/java/com/peachpasswords/sync/Ggwave.kt+0 −41
@@ -1,41 +0,0 @@1-package com.peachpasswords.sync2-3-enum class GgwaveProtocol {4- AUDIBLE_NORMAL,5- AUDIBLE_FAST,6- AUDIBLE_FASTEST,7- ULTRASOUND_NORMAL,8- ULTRASOUND_FAST,9- ULTRASOUND_FASTEST,10- DT_NORMAL,11- DT_FAST,12- DT_FASTEST,13-}14-15-interface ChirpCodec {16- fun encode(payload: ByteArray): FloatArray17- fun decode(samples: FloatArray): ByteArray?18- fun free()19-}20-21-data class ChirpCodecOptions(22- val sampleRate: Int,23- val protocol: GgwaveProtocol = GgwaveProtocol.ULTRASOUND_FASTEST,24- val volumePercent: Int = 10,25-)26-27-typealias ChirpCodecFactory = suspend (ChirpCodecOptions) -> ChirpCodec28-29-/**30- * Native ggwave binding placeholder.31- *32- * The browser source loads vendored JS/WASM at runtime. Android needs a33- * platform-specific audio bridge plus native codec binding, which is a34- * separate implementation step. The coordinator and tests inject their own35- * codec so the pairing protocol can still be exercised now.36- */37-suspend fun createChirpCodec(opts: ChirpCodecOptions): ChirpCodec {38- throw UnsupportedOperationException(39- "Android ggwave codec is not yet implemented (sampleRate=${opts.sampleRate})",40- )41-}app/src/main/java/com/peachpasswords/sync/PairingFrames.kt+14 −0
@@ -8,6 +8,20 @@88 import java.io.OutputStream99 1010 /**11+ * Length-delimited frame IO for a pairing/sync connection.12+ *13+ * Originally declared alongside the Chirp audio pairing that has since been14+ * removed, but it was never audio-specific — it is just "send me a frame, give15+ * me a frame", which the Noise NK pipeline and the adoption flow both build on.16+ * It lives here now, with the frame helpers that use it.17+ */18+interface PairingTransport {19+ suspend fun send(msg: ByteArray)20+ suspend fun receive(): ByteArray21+ suspend fun close()22+}23+24+/**1125 * Length-prefixed framing for the C7 TCP transport, shared by [PairingServer]1226 * and [PairingTransport] so neither duplicates the wire format.1327 *app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+0 −2
@@ -25,7 +25,6 @@2525 import com.peachpasswords.ui.components.CopyFeedbackOverlay2626 import com.peachpasswords.ui.lock.LockScreen2727 import com.peachpasswords.ui.onboarding.OnboardingScreen28-import com.peachpasswords.ui.pairing.ChirpPairScreen2928 import com.peachpasswords.ui.pairing.CodePairScreen3029 import com.peachpasswords.ui.recovery.RecoveryWordEntryScreen3130 import com.peachpasswords.ui.settings.PanicScreen@@ -230,7 +229,6 @@230229 },231230 )232231 }233- composable(PeachNav.ChirpPair.route) { ChirpPairScreen(onBack = back) }234232 composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }235233 composable(PeachNav.Health.route) {236234 VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })app/src/main/java/com/peachpasswords/ui/PeachNav.kt+0 −1
@@ -39,7 +39,6 @@3939 data object Settings : PeachNav("settings")4040 data object CodexGenerate : PeachNav("codex/generate")4141 data object CodexRestore : PeachNav("codex/restore")42- data object ChirpPair : PeachNav("chirp/pair")4342 data object CodePair : PeachNav("pair/code")4443 data object Health : PeachNav("health")4544 data object Trash : PeachNav("trash")app/src/main/java/com/peachpasswords/ui/lock/LockScreen.kt+0 −1
@@ -372,7 +372,6 @@372372 val tabs = listOf(373373 "PeachScript" to PeachNav.Import,374374 "CSV" to PeachNav.Import,375- "From Phone" to PeachNav.ChirpPair,376375 "Codex" to PeachNav.CodexRestore,377376 )378377 Column(horizontalAlignment = Alignment.CenterHorizontally) {app/src/main/java/com/peachpasswords/ui/pairing/ChirpPairScreen.kt+0 −242
@@ -1,242 +0,0 @@1-package com.peachpasswords.ui.pairing2-3-import androidx.compose.animation.core.RepeatMode4-import androidx.compose.animation.core.animateFloat5-import androidx.compose.animation.core.infiniteRepeatable6-import androidx.compose.animation.core.rememberInfiniteTransition7-import androidx.compose.animation.core.tween8-import androidx.compose.foundation.background9-import androidx.compose.foundation.border10-import androidx.compose.foundation.clickable11-import androidx.compose.foundation.layout.Box12-import androidx.compose.foundation.layout.Column13-import androidx.compose.foundation.layout.Row14-import androidx.compose.foundation.layout.Spacer15-import androidx.compose.foundation.layout.fillMaxSize16-import androidx.compose.foundation.layout.fillMaxWidth17-import androidx.compose.foundation.layout.height18-import androidx.compose.foundation.layout.padding19-import androidx.compose.foundation.layout.size20-import androidx.compose.foundation.layout.systemBarsPadding21-import androidx.compose.material.icons.Icons22-import androidx.compose.material.icons.automirrored.rounded.ArrowBack23-import androidx.compose.material3.Icon24-import androidx.compose.foundation.shape.CircleShape25-import androidx.compose.foundation.shape.RoundedCornerShape26-import androidx.compose.material3.Text27-import androidx.compose.runtime.Composable28-import androidx.compose.runtime.getValue29-import androidx.compose.ui.Alignment30-import androidx.compose.ui.Modifier31-import androidx.compose.ui.draw.clip32-import androidx.compose.ui.graphics.Color33-import androidx.compose.ui.graphics.graphicsLayer34-import androidx.compose.ui.text.font.FontStyle35-import androidx.compose.ui.text.style.TextAlign36-import androidx.compose.ui.unit.dp37-import androidx.compose.ui.unit.em38-import androidx.compose.ui.unit.sp39-import androidx.hilt.navigation.compose.hiltViewModel40-import androidx.lifecycle.compose.collectAsStateWithLifecycle41-import com.peachpasswords.sync.PairingPhase42-import com.peachpasswords.ui.components.BirdAnimation43-import com.peachpasswords.ui.components.PairingAnimState44-import com.peachpasswords.ui.components.PeachTopBar45-import com.peachpasswords.ui.theme.LocalPeachAccent46-import com.peachpasswords.ui.theme.PeachSpacing47-import com.peachpasswords.ui.theme.peachColors48-import com.peachpasswords.ui.theme.PeachType49-import com.peachpasswords.ui.theme.ShellBackground50-51-@Composable52-fun ChirpPairScreen(53- onBack: () -> Unit,54- vm: ChirpPairViewModel = hiltViewModel(),55-) {56- val progress by vm.progress.collectAsStateWithLifecycle()57- val sas by vm.sasCode.collectAsStateWithLifecycle()58- val accent = LocalPeachAccent.current59- val phase = progress.phase60-61- ShellBackground {62- Column(63- modifier = Modifier64- .fillMaxSize()65- .systemBarsPadding()66- .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),67- ) {68- PeachTopBar(69- title = "Sync",70- leading = {71- Icon(72- Icons.AutoMirrored.Rounded.ArrowBack,73- contentDescription = "back",74- tint = peachColors.T0,75- modifier = Modifier76- .size(22.dp)77- .clip(RoundedCornerShape(PeachSpacing.sm))78- .clickable(onClick = onBack),79- )80- },81- actions = { StatusChip(phase, accent) },82- )83-84- Spacer(Modifier.height(PeachSpacing.sm))85-86- Box(87- modifier = Modifier.fillMaxWidth().weight(1f),88- contentAlignment = Alignment.Center,89- ) {90- BirdAnimation(91- state = animState(phase),92- modifier = Modifier.fillMaxWidth().height(220.dp),93- )94- }95-96- // SAS is a phase, not a modal — the browser keeps the birds on97- // screen above it. Scrimming them behind a full-screen overlay made98- // the code sit in the middle of a half-lit bird.99- val code = sas100- if (code != null) {101- SasBody(102- code = code,103- accent = accent,104- onConfirm = { if (vm.isDebug && phase == PairingPhase.HANDSHAKE) vm.demoComplete() else vm.confirmSas(true) },105- onCancel = { vm.confirmSas(false); vm.reset() },106- )107- } else {108- Copy(phase, progress.peerDeviceName, progress.errorMessage)109- Spacer(Modifier.height(PeachSpacing.lg))110- PrimaryAction(phase, vm)111- }112- Spacer(Modifier.height(PeachSpacing.md))113- }114- }115-}116-117-private fun animState(phase: PairingPhase): PairingAnimState = when (phase) {118- PairingPhase.IDLE -> PairingAnimState.WAITING119- PairingPhase.LISTENING, PairingPhase.CHIRPING, PairingPhase.HANDSHAKE -> PairingAnimState.LISTENING120- PairingPhase.PAIRED -> PairingAnimState.CONNECTED121- else -> PairingAnimState.WAITING122-}123-124-@Composable125-private fun StatusChip(phase: PairingPhase, accent: Color) {126- val (label, dot) = when (phase) {127- PairingPhase.IDLE -> "ready" to peachColors.T2128- PairingPhase.LISTENING, PairingPhase.CHIRPING -> "listening" to accent129- PairingPhase.HANDSHAKE -> "verifying" to accent130- PairingPhase.PAIRED -> "paired" to Color(0xFF7FB069)131- PairingPhase.MIC_DENIED -> "mic blocked" to peachColors.Breach132- else -> "interrupted" to peachColors.Breach133- }134- val active = phase == PairingPhase.LISTENING || phase == PairingPhase.CHIRPING || phase == PairingPhase.HANDSHAKE135- // Pulsing dot while the pairing is live (1.0→1.4 over 1.2s, spec Phase 7).136- val pulse = rememberInfiniteTransition(label = "chip-pulse")137- val scale by pulse.animateFloat(138- initialValue = 1f, targetValue = 1.4f,139- animationSpec = infiniteRepeatable(tween(1200), RepeatMode.Reverse), label = "chip-pulse-scale",140- )141- val dotScale = if (active) scale else 1f142- Row(143- modifier = Modifier144- .clip(CircleShape)145- .background(accent.copy(alpha = 0.12f))146- .border(1.dp, accent.copy(alpha = 0.28f), CircleShape)147- .padding(horizontal = 10.dp, vertical = PeachSpacing.xs),148- verticalAlignment = Alignment.CenterVertically,149- ) {150- Box(Modifier.size(4.dp).graphicsLayer { scaleX = dotScale; scaleY = dotScale }.clip(CircleShape).background(dot))151- Spacer(Modifier.size(PeachSpacing.xs))152- Text(label, color = peachColors.T1, fontFamily = PeachType.Mono, fontSize = 9.sp, letterSpacing = 0.08.em)153- }154-}155-156-@Composable157-private fun Copy(phase: PairingPhase, peer: String?, error: String?) {158- val (headline, sub) = when (phase) {159- PairingPhase.IDLE -> "Pair a device" to "Sync over your local network. No cloud, no account, no servers."160- PairingPhase.LISTENING, PairingPhase.CHIRPING -> "Listening…" to "Hold this phone near the other device and start pairing there too."161- PairingPhase.HANDSHAKE -> "Almost there" to "Check that the code below matches on ${peer ?: "the other device"}."162- PairingPhase.PAIRED -> "Paired" to "${peer ?: "Your devices"} will now keep your vault in step over the local network."163- PairingPhase.TIMEOUT -> "No chirp heard" to "Nothing answered. Move the devices closer and try again."164- PairingPhase.MIC_DENIED -> "Microphone needed" to "Pairing listens for an inaudible chirp. Grant mic access to continue."165- PairingPhase.VERSION_MISMATCH -> "Version mismatch" to "Update Peach on both devices, then try pairing again."166- else -> "Pairing interrupted" to (error ?: "Something went wrong. Try again.")167- }168- Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {169- Text(headline, color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 26.sp, textAlign = TextAlign.Center)170- Spacer(Modifier.height(PeachSpacing.sm))171- Text(sub, color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center)172- }173-}174-175-@Composable176-private fun PrimaryAction(phase: PairingPhase, vm: ChirpPairViewModel) {177- val accent = LocalPeachAccent.current178- when (phase) {179- PairingPhase.IDLE -> PillButton("Start pairing", accent, filled = true) { vm.startPairing() }180- PairingPhase.LISTENING, PairingPhase.CHIRPING, PairingPhase.HANDSHAKE ->181- PillButton("Cancel", peachColors.T2, filled = false) { vm.reset() }182- PairingPhase.PAIRED -> PillButton("Done", accent, filled = true) { vm.reset() }183- else -> PillButton("Try again", accent, filled = true) { vm.startPairing() }184- }185- if (vm.isDebug && phase == PairingPhase.IDLE) {186- Spacer(Modifier.height(PeachSpacing.sm))187- Text(188- "demo flow",189- color = peachColors.T3,190- fontFamily = PeachType.Mono,191- fontSize = 9.sp,192- modifier = Modifier.fillMaxWidth().clickable { vm.runDemo() }.padding(8.dp),193- textAlign = TextAlign.Center,194- )195- }196-}197-198-@Composable199-internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {200- val accent = LocalPeachAccent.current201- Box(202- modifier = Modifier203- .fillMaxWidth()204- .clip(RoundedCornerShape(14.dp))205- .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)206- .clickable(onClick = onClick)207- .padding(vertical = PeachSpacing.md),208- contentAlignment = Alignment.Center,209- ) {210- Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)211- }212-}213-214-@Composable215-private fun SasBody(code: String, accent: Color, onConfirm: () -> Unit, onCancel: () -> Unit) {216- Column(217- modifier = Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.md),218- horizontalAlignment = Alignment.CenterHorizontally,219- ) {220- Text("Verify the code", color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp)221- Spacer(Modifier.height(PeachSpacing.xs))222- Text(223- "Only continue if this code is identical on both screens. This is what proves you're talking to your own device.",224- color = peachColors.T2,225- fontFamily = PeachType.Body,226- fontSize = 13.sp,227- textAlign = TextAlign.Center,228- )229- Spacer(Modifier.height(PeachSpacing.lg))230- Text(231- text = code,232- color = accent,233- fontFamily = PeachType.Mono,234- fontSize = 38.sp,235- letterSpacing = 6.sp,236- )237- Spacer(Modifier.height(PeachSpacing.lg))238- PillButton("They match — pair", accent, filled = true, onClick = onConfirm)239- Spacer(Modifier.height(PeachSpacing.sm))240- PillButton("Cancel", peachColors.T2, filled = false, onClick = onCancel)241- }242-}app/src/main/java/com/peachpasswords/ui/pairing/ChirpPairViewModel.kt+0 −187
@@ -1,187 +0,0 @@1-package com.peachpasswords.ui.pairing2-3-import android.content.Context4-import androidx.lifecycle.ViewModel5-import androidx.lifecycle.viewModelScope6-import com.peachpasswords.BuildConfig7-import com.peachpasswords.license.LicenseRepository8-import com.peachpasswords.sync.AudioIO9-import com.peachpasswords.sync.ChirpPairingDeps10-import com.peachpasswords.sync.LocalChirpIdentity11-import com.peachpasswords.sync.PairedDevice12-import com.peachpasswords.sync.PairingEndpoint13-import com.peachpasswords.sync.PairingPhase14-import com.peachpasswords.sync.PairingProgress15-import com.peachpasswords.sync.PairingRole16-import com.peachpasswords.sync.PairingServer17-import com.peachpasswords.sync.PairingTransport18-import com.peachpasswords.sync.PairedDeviceStore19-import com.peachpasswords.sync.SocketFrameIO20-import com.peachpasswords.sync.buildLocalIdentity21-import com.peachpasswords.sync.openAndroidAudioIO22-import com.peachpasswords.sync.runChirpPairing23-import com.peachpasswords.vault.VaultCrypto24-import com.peachpasswords.vault.VaultRepository25-import dagger.hilt.android.lifecycle.HiltViewModel26-import dagger.hilt.android.qualifiers.ApplicationContext27-import kotlinx.coroutines.CompletableDeferred28-import kotlinx.coroutines.Dispatchers29-import kotlinx.coroutines.delay30-import kotlinx.coroutines.flow.MutableStateFlow31-import kotlinx.coroutines.flow.StateFlow32-import kotlinx.coroutines.flow.asStateFlow33-import kotlinx.coroutines.launch34-import kotlinx.coroutines.withContext35-import java.net.ServerSocket36-import java.net.Socket37-import javax.inject.Inject38-39-/** TCP connect timeout for the initiator's pairing socket. */40-private const val CONNECT_TIMEOUT_MS = 5_00041-42-/**43- * Drives the Chirp pairing flow and surfaces its phase + SAS code to44- * [ChirpPairScreen]. The real audio bridge ([openAndroidAudioIO]) lands in45- * Phase 12; until then [startPairing] runs the genuine orchestrator and46- * surfaces the audio-unavailable phase honestly, while [runDemo] (DEBUG only)47- * steps the visuals through all three acts so the animation and SAS screen can48- * be verified on a single device.49- */50-@HiltViewModel51-class ChirpPairViewModel @Inject constructor(52- @ApplicationContext private val context: Context,53- private val vault: VaultRepository,54- private val deviceStore: PairedDeviceStore,55- private val license: LicenseRepository,56-) : ViewModel() {57-58- private val _progress = MutableStateFlow(PairingProgress(PairingPhase.IDLE))59- val progress: StateFlow<PairingProgress> = _progress.asStateFlow()60-61- private val _sasCode = MutableStateFlow<String?>(null)62- val sasCode: StateFlow<String?> = _sasCode.asStateFlow()63-64- val isDebug: Boolean = BuildConfig.DEBUG65-66- private var sasGate: CompletableDeferred<Boolean>? = null67-68- fun startPairing() {69- val master = vault.syncMasterKey() ?: run {70- _progress.value = PairingProgress(PairingPhase.HANDSHAKE_FAILED, errorMessage = "vault_locked")71- return72- }73- _progress.value = PairingProgress(PairingPhase.LISTENING)74- viewModelScope.launch {75- try {76- runChirpPairing(deps(master), role = PairingRole.INITIATOR)77- } catch (e: Exception) {78- if (_progress.value.phase.let { it != PairingPhase.TIMEOUT && it != PairingPhase.MIC_DENIED }) {79- _progress.value = PairingProgress(80- PairingPhase.HANDSHAKE_FAILED,81- errorMessage = e.message ?: "pairing_failed",82- )83- }84- } finally {85- VaultCrypto.zero(master)86- }87- }88- }89-90- fun confirmSas(accepted: Boolean) {91- sasGate?.complete(accepted)92- _sasCode.value = null93- }94-95- fun reset() {96- sasGate?.complete(false)97- sasGate = null98- _sasCode.value = null99- _progress.value = PairingProgress(PairingPhase.IDLE)100- }101-102- /** DEBUG: walk the three acts so the screen can be exercised without a peer. */103- fun runDemo() {104- if (!isDebug) return105- viewModelScope.launch {106- _progress.value = PairingProgress(PairingPhase.IDLE)107- delay(700)108- _progress.value = PairingProgress(PairingPhase.LISTENING)109- delay(1800)110- _progress.value = PairingProgress(PairingPhase.HANDSHAKE, peerDeviceName = "June's Laptop")111- _sasCode.value = "418 207"112- }113- }114-115- /** DEBUG: complete the demo into the paired act after SAS confirmation. */116- fun demoComplete() {117- _sasCode.value = null118- _progress.value = PairingProgress(PairingPhase.PAIRED, peerDeviceName = "June's Laptop")119- }120-121- private fun deps(masterKey: ByteArray): ChirpPairingDeps = object : ChirpPairingDeps {122- override val chirpTimeoutMs: Long = 15_000123-124- override suspend fun fetchLocalIdentity(): LocalChirpIdentity {125- val base = buildLocalIdentity(context, masterKey)126- // Advertise our signed license iff this device is Pro, so the peer127- // can activate it after SAS — pairing carries Pro across.128- return if (license.isProActive()) base.copy(license = license.getLicenseKey()) else base129- }130-131- override suspend fun completePairing(paired: PairedDevice) {132- // Persist the newly-trusted peer so Sync can dial it later (C7 wiring).133- deviceStore.upsert(paired)134- }135-136- override suspend fun activatePeerLicense(license: String) {137- // Receive Pro from an already-Pro peer. Verify the signature; keep138- // our own license if we already have one.139- if (this@ChirpPairViewModel.license.isProActive()) return140- if (this@ChirpPairViewModel.license.validateLicense(license)) {141- this@ChirpPairViewModel.license.storeLicense(license)142- }143- }144-145- override suspend fun confirmSAS(sas: String): Boolean {146- val gate = CompletableDeferred<Boolean>()147- sasGate = gate148- _sasCode.value = sas149- return gate.await()150- }151-152- override suspend fun cacheLastKnownIp(pubkeyHex: String, ip: String) {153- deviceStore.getAll().firstOrNull { it.longTermPublicKeyHex == pubkeyHex }?.let {154- deviceStore.updateLastSeen(it.deviceId, ip)155- }156- }157- override suspend fun getLocalSubnet(): String? = null158-159- // Initiator side: dial the peer and frame the TCP socket as a PairingTransport.160- override suspend fun openPairingTransport(endpoint: PairingEndpoint): PairingTransport =161- withContext(Dispatchers.IO) {162- val socket = Socket()163- socket.connect(java.net.InetSocketAddress(endpoint.ip, endpoint.port), CONNECT_TIMEOUT_MS)164- SocketFrameIO(socket.getInputStream(), socket.getOutputStream(), onClose = {165- withContext(Dispatchers.IO) { runCatching { socket.close() } }166- })167- }168-169- // Responder side: bind an ephemeral port, accept one peer, frame it.170- override suspend fun openPairingServer(): PairingServer =171- withContext(Dispatchers.IO) {172- val server = ServerSocket(0)173- val socket = server.accept()174- val peerIp = socket.inetAddress?.hostAddress ?: ""175- PairingServer(176- transport = SocketFrameIO(socket.getInputStream(), socket.getOutputStream(), onClose = {177- withContext(Dispatchers.IO) { runCatching { socket.close() }; runCatching { server.close() } }178- }),179- peerIp = peerIp,180- )181- }182- override suspend fun openAudio(): AudioIO = openAndroidAudioIO()183- override fun onProgress(progress: PairingProgress) {184- _progress.value = progress185- }186- }187-}app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+22 −0
@@ -1,5 +1,10 @@11 package com.peachpasswords.ui.pairing22 3+import androidx.compose.foundation.background4+import androidx.compose.foundation.clickable5+import androidx.compose.foundation.shape.RoundedCornerShape6+import androidx.compose.ui.draw.clip7+import androidx.compose.ui.graphics.Color38 import androidx.compose.foundation.layout.Arrangement49 import androidx.compose.foundation.layout.Box510 import androidx.compose.foundation.layout.Column@@ -156,6 +161,23 @@156161 }157162 Spacer(Modifier.height(PeachSpacing.md))158163 }164+ }165+}166+167+/** Full-width pill used for every action on the pairing screens. */168+@Composable169+internal fun PillButton(label: String, color: Color, filled: Boolean, onClick: () -> Unit) {170+ val accent = LocalPeachAccent.current171+ Box(172+ modifier = Modifier173+ .fillMaxWidth()174+ .clip(RoundedCornerShape(14.dp))175+ .background(if (filled) accent.copy(alpha = 0.14f) else Color.Transparent)176+ .clickable(onClick = onClick)177+ .padding(vertical = PeachSpacing.md),178+ contentAlignment = Alignment.Center,179+ ) {180+ Text(label, color = if (filled) accent else color, fontFamily = PeachType.Mono, fontSize = 13.sp)159181 }160182 }161183 app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+1 −1
@@ -633,7 +633,7 @@633633 }634634 // The whole row is the tap target — no separate "open" button (browser635635 // sync-hub parity: every settings row is tappable, the chevron signals it).636- RowLine(fg, Modifier.clickable { onNavigate(PeachNav.ChirpPair) }) {636+ RowLine(fg, Modifier.clickable { onNavigate(PeachNav.CodePair) }) {637637 Name("Sync hub", fg)638638 Text("›", color = fg.copy(alpha = 0.6f), fontFamily = PeachType.Mono, fontSize = 15.sp)639639 }app/src/test/java/com/peachpasswords/sync/ChirpPairingTest.kt+0 −256
@@ -1,256 +0,0 @@1-package com.peachpasswords.sync2-3-import kotlinx.coroutines.runBlocking4-import org.junit.Assert.assertEquals5-import org.junit.Assert.assertFalse6-import org.junit.Assert.assertTrue7-import org.junit.Test8-9-private fun fakeCodecFactory(): ChirpCodecFactory = { _ ->10- object : ChirpCodec {11- override fun encode(payload: ByteArray): FloatArray {12- val out = FloatArray(maxOf(payload.size, 128))13- for (i in payload.indices) {14- out[i] = (payload[i].toInt() and 0xff) / 255f15- }16- for (i in payload.size until out.size) {17- out[i] = 99f18- }19- return out20- }21-22- override fun decode(samples: FloatArray): ByteArray? {23- if (samples.size < CHIRP_PAYLOAD_LENGTH) return null24- val buf = ByteArray(CHIRP_PAYLOAD_LENGTH)25- for (i in 0 until CHIRP_PAYLOAD_LENGTH) {26- val v = samples[i]27- if (v == 99f) return null28- buf[i] = (v * 255f).toInt().toByte()29- }30- return buf31- }32-33- override fun free() = Unit34- }35-}36-37-private fun fakeAudio(injected: FloatArray? = null): Pair<AudioIO, (FloatArray) -> Unit> {38- var pending = injected ?: FloatArray(0)39- var playCount = 040- var closed = false41- val io = object : AudioIO {42- override val sampleRate: Int = 48_00043- override suspend fun play(waveform: FloatArray) {44- playCount++45- }46-47- override suspend fun pullSamples(): FloatArray {48- val out = pending49- pending = FloatArray(0)50- return out51- }52-53- override suspend fun close() {54- closed = true55- }56- }57- return io to { samples ->58- val next = FloatArray(pending.size + samples.size)59- pending.copyInto(next, 0)60- samples.copyInto(next, pending.size)61- pending = next62- }63-}64-65-private fun chirpWave(payload: ChirpPayload): FloatArray {66- val bytes = encodeChirpPayload(payload)67- val wave = FloatArray(128)68- for (i in bytes.indices) {69- wave[i] = (bytes[i].toInt() and 0xff) / 255f70- }71- for (i in bytes.size until wave.size) {72- wave[i] = 99f73- }74- return wave75-}76-77-class ChirpPairingTest {78- @Test79- fun `createPairingAttempt rolls fresh ephemerals and nonce`() {80- val a = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION))81- val b = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION))82- assertFalse(a.ephemeralPublicKey.contentEquals(b.ephemeralPublicKey))83- assertEquals(16, a.sessionNonce.size)84- assertTrue(a.outgoingPayload.ephemeralPublicKey.contentEquals(a.ephemeralPublicKey))85- assertEquals(deviceTypeToByte(DeviceType.EXTENSION), a.outgoingPayload.deviceType)86- }87-88- @Test89- fun `runChirpExchange returns remote payload when received`() = runBlocking {90- val theirs = createPairingAttempt(deviceTypeToByte(DeviceType.ANDROID))91- val ours = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION))92- val (io, inject) = fakeAudio()93- inject(chirpWave(theirs.outgoingPayload))94-95- val result = runChirpExchange(96- io = io,97- opts = ChirpExchangeOptions(98- attempt = ours,99- timeoutMs = 2_000,100- ),101- codecFactory = fakeCodecFactory(),102- )103-104- assertTrue(result.remoteChirp.ephemeralPublicKey.contentEquals(theirs.ephemeralPublicKey))105- assertTrue(result.remoteChirp.sessionNonce.contentEquals(theirs.sessionNonce))106- assertEquals(deviceTypeToByte(DeviceType.ANDROID), result.remoteChirp.deviceType)107- }108-109- @Test110- fun `runChirpExchange ignores own chirp and keeps listening`() = runBlocking {111- val ours = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION))112- val theirs = createPairingAttempt(deviceTypeToByte(DeviceType.ANDROID))113- val (io, inject) = fakeAudio(chirpWave(ours.outgoingPayload))114- val peerWave = chirpWave(theirs.outgoingPayload)115- kotlin.concurrent.thread {116- Thread.sleep(150)117- inject(peerWave)118- }119-120- val result = runChirpExchange(121- io = io,122- opts = ChirpExchangeOptions(123- attempt = ours,124- timeoutMs = 3_000,125- ),126- codecFactory = fakeCodecFactory(),127- )128-129- assertTrue(result.remoteChirp.ephemeralPublicKey.contentEquals(theirs.ephemeralPublicKey))130- }131-132- @Test133- fun `runChirpExchange times out when no chirp arrives`() = runBlocking {134- val ours = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION))135- val (io, _) = fakeAudio()136- try {137- runChirpExchange(138- io = io,139- opts = ChirpExchangeOptions(140- attempt = ours,141- timeoutMs = 200,142- ),143- codecFactory = fakeCodecFactory(),144- )145- } catch (err: ChirpExchangeError) {146- assertEquals(ChirpExchangeErrorKind.TIMEOUT, err.kind)147- return@runBlocking148- }149- throw AssertionError("expected timeout")150- }151-152- @Test153- fun `runChirpExchange raises version mismatch on future protocol`() = runBlocking {154- val ours = createPairingAttempt(deviceTypeToByte(DeviceType.EXTENSION))155- val theirs = createPairingAttempt(deviceTypeToByte(DeviceType.ANDROID))156- val tweaked = theirs.outgoingPayload.copy(version = 0x02)157- val (io, inject) = fakeAudio()158- inject(chirpWave(tweaked))159-160- try {161- runChirpExchange(162- io = io,163- opts = ChirpExchangeOptions(164- attempt = ours,165- timeoutMs = 2_000,166- ),167- codecFactory = fakeCodecFactory(),168- )169- } catch (err: ChirpExchangeError) {170- assertEquals(ChirpExchangeErrorKind.VERSION_MISMATCH, err.kind)171- return@runBlocking172- }173- throw AssertionError("expected version mismatch")174- }175-176- @Test177- fun `pair frame round-trips an optional Pro license`() {178- val frame = PairingFrame(179- type = "PAIR_IDENTITY",180- deviceId = "a",181- deviceName = "Pixel",182- deviceType = DeviceType.ANDROID,183- longTermPublicKeyHex = "0".repeat(64),184- signingPublicKeyHex = "1".repeat(64),185- license = "PPK-AAA-BBB",186- )187- val json = encodePairingFrame(frame).decodeToString()188- assertTrue(json.contains("\"license\":\"PPK-AAA-BBB\""))189- assertEquals("PPK-AAA-BBB", decodePairingFrame(json.encodeToByteArray()).license)190-191- // Omitted entirely when null (forward-compatible with peers that ignore it).192- val noLicense = encodePairingFrame(frame.copy(license = null)).decodeToString()193- assertFalse(noLicense.contains("license"))194- }195-196- @Test197- fun `decodes a browser PAIR_IDENTITY frame carrying a license`() {198- val browserJson = "{\"type\":\"PAIR_IDENTITY\"," +199- "\"device_id\":\"ext-1\",\"device_name\":\"Firefox\"," +200- "\"device_type\":\"extension\"," +201- "\"long_term_public_key_hex\":\"${"0".repeat(64)}\"," +202- "\"license\":\"PPK-XYZ-123\"}"203- assertEquals("PPK-XYZ-123", decodePairingFrame(browserJson.encodeToByteArray()).license)204- }205-206- @Test207- fun `runChirpPairing emits listening before timeout`() = runBlocking {208- val trail = mutableListOf<PairingProgress>()209- try {210- runChirpPairing(211- deps = object : ChirpPairingDeps {212- override suspend fun fetchLocalIdentity(): LocalChirpIdentity {213- return LocalChirpIdentity(214- deviceId = "ext",215- deviceName = "Firefox",216- deviceType = DeviceType.EXTENSION,217- longTermPublicKeyHex = "0".repeat(64),218- signingPublicKeyHex = "1".repeat(64),219- )220- }221-222- override suspend fun completePairing(paired: PairedDevice) = Unit223- override suspend fun confirmSAS(sas: String): Boolean = true224- override suspend fun cacheLastKnownIp(pubkeyHex: String, ip: String) = Unit225- override suspend fun getLocalSubnet(): String? = "192.168.1"226- override suspend fun openPairingTransport(endpoint: PairingEndpoint): PairingTransport {227- throw IllegalStateException("unreached")228- }229- override suspend fun openPairingServer(): PairingServer {230- throw IllegalStateException("unreached")231- }232- override suspend fun openAudio(): AudioIO {233- return object : AudioIO {234- override val sampleRate: Int = 48_000235- override suspend fun play(waveform: FloatArray) = Unit236- override suspend fun pullSamples(): FloatArray = FloatArray(0)237- override suspend fun close() = Unit238- }239- }240-241- override val chirpTimeoutMs: Long = 200242- override fun onProgress(progress: PairingProgress) {243- trail.add(progress)244- }245- },246- role = PairingRole.RESPONDER,247- codecFactory = fakeCodecFactory(),248- )249- } catch (_: ChirpPairingFailure) {250- // expected timeout251- }252-253- assertEquals(PairingPhase.IDLE, trail.first().phase)254- assertEquals(PairingPhase.LISTENING, trail[1].phase)255- }256-}app/src/test/java/com/peachpasswords/sync/ChirpSyncTransportTest.kt+5 −5
@@ -25,7 +25,7 @@2525 val now = 1_000_000L2626 probeSubnetForPeer(2727 subnet = "10.0.0",28- port = CHIRP_PAIRING_PORT,28+ port = SyncReconnectListener.SYNC_PORT,2929 ourLongTermPrivateKey = ByteArray(32),3030 ourLongTermPublicKey = ByteArray(32),3131 expectedRemotePubkeyHex = peerPubkey,@@ -42,7 +42,7 @@4242 val now = 1_000_000L4343 probeSubnetForPeer(4444 subnet = "10.0.0",45- port = CHIRP_PAIRING_PORT,45+ port = SyncReconnectListener.SYNC_PORT,4646 ourLongTermPrivateKey = ByteArray(32),4747 ourLongTermPublicKey = ByteArray(32),4848 expectedRemotePubkeyHex = peerPubkey,@@ -58,7 +58,7 @@5858 val now = 1_000_000L5959 probeSubnetForPeer(6060 subnet = "10.0.0",61- port = CHIRP_PAIRING_PORT,61+ port = SyncReconnectListener.SYNC_PORT,6262 ourLongTermPrivateKey = ByteArray(32),6363 ourLongTermPublicKey = ByteArray(32),6464 expectedRemotePubkeyHex = peerPubkey,@@ -68,7 +68,7 @@6868 )6969 val result = probeSubnetForPeer(7070 subnet = "10.0.0",71- port = CHIRP_PAIRING_PORT,71+ port = SyncReconnectListener.SYNC_PORT,7272 ourLongTermPrivateKey = ByteArray(32),7373 ourLongTermPublicKey = ByteArray(32),7474 expectedRemotePubkeyHex = peerPubkey,@@ -85,7 +85,7 @@8585 val other = "cd".repeat(32)8686 probeSubnetForPeer(8787 subnet = "10.0.0",88- port = CHIRP_PAIRING_PORT,88+ port = SyncReconnectListener.SYNC_PORT,8989 ourLongTermPrivateKey = ByteArray(32),9090 ourLongTermPublicKey = ByteArray(32),9191 expectedRemotePubkeyHex = peerPubkey,