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
15 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 −5
app/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" />

view file

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-}

view file at the parent

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-}

view file at the parent

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-}

view file at the parent

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-}

view file at the parent

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  *

view file

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)) })

view file

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")

view file

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) {

view file

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-}

view file at the parent

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-}

view file at the parent

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 

view file

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     }

view file

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-}

view file at the parent

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,

view file