818bd9bb6b50

fix: Enforce Pro entitlement during pairing (v2.8.1)

time
2026-07-29 00:22 UTC · 2 months ago
machine
koh2
parent
234156b10775
13 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+3 −0app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+6 −1app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+41 −2app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+6 −1app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+10 −4app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+19 −2app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+27 −14app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+10 −0app/src/main/java/com/peachpasswords/ui/pairing/PairingProRequired.kt+137 −0app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+2 −0app/src/test/java/com/peachpasswords/sync/RelayAdoptionInitiatorSourceTest.kt+2 −1app/src/test/java/com/peachpasswords/sync/VaultAdoptTest.kt+79 −3
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24014220-        versionName "2.8.0"19+        versionCode 24014320+        versionName "2.8.1"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+3 −0
@@ -194,6 +194,9 @@194194             override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()195195             override suspend fun vaultId(): String = sourceDeps.vaultId()196196             override suspend fun license(): String? = sourceDeps.license()197+            override suspend fun applyLicense(license: String) {198+                sourceDeps.applyLicense(license)199+            }197200             override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true198201             override suspend fun upsertPairedDevice(device: PairedDevice) {199202                 sourceDeps.upsertPairedDevice(device)

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+6 −1
@@ -213,6 +213,8 @@213213                 receiverDeps.applyLicense(license)214214             }215215 216+            override suspend fun license(): String? = receiverDeps.license()217+216218             override fun onError(error: String) {217219                 receiverDeps.onError(error)218220             }@@ -282,6 +284,9 @@282284             override suspend fun rootKeyHex(): String = sourceDeps.rootKeyHex()283285             override suspend fun vaultId(): String = sourceDeps.vaultId()284286             override suspend fun license(): String? = sourceDeps.license()287+            override suspend fun applyLicense(license: String) {288+                sourceDeps.applyLicense(license)289+            }285290             override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true286291             override suspend fun upsertPairedDevice(device: PairedDevice) {287292                 sourceDeps.upsertPairedDevice(device)@@ -291,4 +296,4 @@291296     } finally {292297         noise.close()293298     }294-}\ No newline at end of file299+}

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+41 −2
@@ -36,6 +36,7 @@3636         @SerialName("device_id") val sessionRequestId: String,3737         @SerialName("device_name") val deviceName: String,3838         @SerialName("device_type") val deviceType: String,39+        val license: String? = null,3940     ) : AdoptionFrame()4041 4142     @Serializable@@ -77,7 +78,14 @@7778     @SerialName("ADOPT_APPLIED")7879     data object Applied : AdoptionFrame()7980 81+    /** Neither peer proved Pro, so the pairing must not commit. */8082     @Serializable83+    @SerialName("ADOPT_REJECT")84+    data class Reject(85+        val reason: String,86+    ) : AdoptionFrame()87+88+    @Serializable8189     data class Source(8290         @SerialName("device_id") val deviceId: String,8391         @SerialName("device_name") val deviceName: String,@@ -114,6 +122,8 @@114122     suspend fun clearPairedDeviceRevocation(deviceId: String) {}115123     suspend fun upsertPairedDevice(device: PairedDevice)116124     suspend fun applyLicense(license: String) {}125+    /** Return this receiver's locally validated Pro key, if present. */126+    suspend fun license(): String? = null117127     val pairState: AdoptionPairState? get() = null118128     fun onError(error: String)119129 }@@ -124,6 +134,8 @@124134     suspend fun rootKeyHex(): String125135     suspend fun vaultId(): String126136     suspend fun license(): String? = null137+    /** Validate and durably store a Pro key offered by the receiver. */138+    suspend fun applyLicense(license: String) {}127139 128140     /**129141      * Ask the user to approve handing this vault to the requesting device. The@@ -169,6 +181,9 @@169181 fun isAdoptApplied(frame: AdoptionFrame?): Boolean =170182     frame is AdoptionFrame.Applied171183 184+fun isAdoptReject(frame: AdoptionFrame?): Boolean =185+    frame is AdoptionFrame.Reject && frame.reason == "ADOPT_PRO_REQUIRED"186+172187 /**173188  * RECEIVER side: ask a source to adopt us. Sends ADOPT_REQUEST, verifies the174189  * granted root key actually derives the advertised vault id (catching a lying or@@ -181,12 +196,14 @@181196     deps: AdoptionReceiverDeps,182197     nowMs: () -> Long = { System.currentTimeMillis() },183198 ): PairedDevice? {199+    val receiverLicense = deps.license()184200     // Fresh every attempt, and deliberately not our real device id: no identity185201     // exists yet at this point in the protocol.186202     sendFrame(channel, AdoptionFrame.Request(187203         sessionRequestId = deps.generateDeviceId(),188204         deviceName = deps.deviceName,189205         deviceType = deps.deviceType.wireValue,206+        license = receiverLicense,190207     ))191208 192209     val confirmed = deps.confirmSas()@@ -197,6 +214,10 @@197214     }198215 199216     val frame = receiveFrame(channel)217+    if (isAdoptReject(frame)) {218+        deps.onError((frame as AdoptionFrame.Reject).reason)219+        return null220+    }200221     if (!isAdoptGrant(frame)) {201222         deps.onError("ADOPT_UNEXPECTED_FRAME")202223         return null@@ -212,6 +233,10 @@212233     } finally {213234         rootKeyBytes.fill(0)214235     }236+    if (receiverLicense == null && grant.license == null) {237+        deps.onError("ADOPT_PRO_REQUIRED")238+        return null239+    }215240 216241     deps.validateSource(grant.source)217242     val vaultId = deps.createVaultFromRootKey(grant.rootKeyHex)@@ -220,7 +245,9 @@220245         return null221246     }222247 223-    grant.license?.let { deps.applyLicense(it) }248+    if (receiverLicense == null) {249+        grant.license?.let { deps.applyLicense(it) }250+    }224251 225252     val identity = deps.deriveLocalIdentity()226253     sendFrame(channel, AdoptionFrame.Identity(@@ -275,13 +302,25 @@275302         return null276303     }277304 305+    var sourceLicense = deps.license()306+    if (sourceLicense == null && request.license != null) {307+        sourceLicense = runCatching {308+            deps.applyLicense(request.license)309+            deps.license()310+        }.getOrNull()311+    }312+    if (sourceLicense == null) {313+        sendFrame(channel, AdoptionFrame.Reject("ADOPT_PRO_REQUIRED"))314+        throw IllegalStateException("ADOPT_PRO_REQUIRED")315+    }316+278317     val rootKeyHex = deps.rootKeyHex()279318     if (!validateRootKeyHex(rootKeyHex)) throw IllegalStateException("ADOPT_INVALID_ROOT_KEY")280319 281320     sendFrame(channel, AdoptionFrame.Grant(282321         rootKeyHex = rootKeyHex,283322         vaultId = deps.vaultId(),284-        license = deps.license(),323+        license = sourceLicense,285324         source = AdoptionFrame.Source(286325             deviceId = deps.sourceIdentity.deviceId,287326             deviceName = deps.sourceIdentity.deviceName,

view file

app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+6 −1
@@ -280,7 +280,12 @@280280                 },281281             )282282         }283-        composable(PeachNav.CodePair.route) { CodePairScreen(onBack = back) }283+        composable(PeachNav.CodePair.route) {284+            CodePairScreen(285+                onBack = back,286+                onOpenPro = { nav(PeachNav.Settings) },287+            )288+        }284289         composable(PeachNav.AdoptVault.route) {285290             val context = LocalContext.current286291             AdoptVaultScreen(

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultScreen.kt+10 −4
@@ -245,10 +245,14 @@245245                 }246246 247247                 is AdoptVaultUiState.Failed -> {248-                    Body(adoptionFailureCopy(current.reason))249-                    Spacer(Modifier.height(PeachSpacing.lg))250-                    PillButton("Start again", LocalPeachAccent.current, filled = true) {251-                        vm.restart()248+                    if (current.reason == "ADOPT_PRO_REQUIRED") {249+                        PairingProRequired(onRetry = vm::restart)250+                    } else {251+                        Body(adoptionFailureCopy(current.reason))252+                        Spacer(Modifier.height(PeachSpacing.lg))253+                        PillButton("Start again", LocalPeachAccent.current, filled = true) {254+                            vm.restart()255+                        }252256                     }253257                 }254258             }@@ -301,6 +305,8 @@301305 }302306 303307 private fun adoptionFailureCopy(reason: String): String = when (reason) {308+    "ADOPT_PRO_REQUIRED" ->309+        "Peach Pro is required on at least one of the two devices before encrypted sync can be paired."304310     "invalid_pairing_invitation" ->305311         "That invitation isn't valid. Scan a fresh QR or copy the whole invitation again."306312     "ADOPT_SAS_REJECTED", "adoption_rejected" ->

view file

app/src/main/java/com/peachpasswords/ui/pairing/AdoptVaultViewModel.kt+19 −2
@@ -296,11 +296,18 @@296296             }297297 298298             override suspend fun applyLicense(licenseKey: String) {299-                if (license.validateLicense(licenseKey)) {300-                    license.storeLicense(licenseKey)299+                if (!license.validateLicense(licenseKey)) {300+                    throw IllegalStateException("license_transfer_failed")301301                 }302+                license.storeLicense(licenseKey)303+                if (!license.isProActive()) {304+                    throw IllegalStateException("license_transfer_failed")305+                }302306             }303307 308+            override suspend fun license(): String? =309+                license.getLicenseKey()?.takeIf(license::validateLicense)310+304311             override fun onError(error: String) {305312                 protocolError = error306313             }@@ -325,6 +332,16 @@325332 326333             override suspend fun license(): String? =327334                 license.getLicenseKey()?.takeIf(license::validateLicense)335+336+            override suspend fun applyLicense(licenseKey: String) {337+                if (!license.validateLicense(licenseKey)) {338+                    throw IllegalStateException("license_transfer_failed")339+                }340+                license.storeLicense(licenseKey)341+                if (!license.isProActive()) {342+                    throw IllegalStateException("license_transfer_failed")343+                }344+            }328345 329346             override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true330347 

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairScreen.kt+27 −14
@@ -54,6 +54,7 @@5454 @Composable5555 fun CodePairScreen(5656     onBack: () -> Unit,57+    onOpenPro: () -> Unit = onBack,5758     vm: CodePairViewModel = hiltViewModel(),5859 ) {5960     val state by vm.state.collectAsStateWithLifecycle()@@ -95,8 +96,12 @@9596                 .systemBarsPadding()9697                 .padding(horizontal = PeachSpacing.lg, vertical = PeachSpacing.md),9798         ) {98-            Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {99-                BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))99+            if (state !is CodePairUiState.Failed ||100+                (state as? CodePairUiState.Failed)?.reason != "ADOPT_PRO_REQUIRED"101+            ) {102+                Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {103+                    BirdAnimation(animState, Modifier.fillMaxWidth().height(220.dp))104+                }100105             }101106 102107             when (val s = state) {@@ -160,21 +165,28 @@160165                 }161166 162167                 is CodePairUiState.Failed -> {163-                    Headline("Couldn't pair", failureCopy(s.reason))164-                    Spacer(Modifier.height(PeachSpacing.lg))165-                    if (s.reason == "sync_disabled" || s.reason == "private_relay_unset") {166-                        PairingRelayChoice(167-                            mode = syncMode,168-                            configuredPrivateUrl = privateRelayUrl,169-                            expanded = true,170-                            collapsible = false,171-                            onExpandedChange = { relayExpanded = it },172-                            onSelectMode = vm::selectRelayMode,173-                            onSavePrivateUrl = vm::savePrivateRelayUrl,168+                    if (s.reason == "ADOPT_PRO_REQUIRED") {169+                        PairingProRequired(170+                            onRetry = vm::restart,171+                            onOpenPro = onOpenPro,174172                         )173+                    } else {174+                        Headline("Couldn't pair", failureCopy(s.reason))175175                         Spacer(Modifier.height(PeachSpacing.md))176+                        if (s.reason == "sync_disabled" || s.reason == "private_relay_unset") {177+                            PairingRelayChoice(178+                                mode = syncMode,179+                                configuredPrivateUrl = privateRelayUrl,180+                                expanded = true,181+                                collapsible = false,182+                                onExpandedChange = { relayExpanded = it },183+                                onSelectMode = vm::selectRelayMode,184+                                onSavePrivateUrl = vm::savePrivateRelayUrl,185+                            )186+                            Spacer(Modifier.height(PeachSpacing.md))187+                        }188+                        PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)176189                     }177-                    PillButton("Back", LocalPeachAccent.current, filled = true, onClick = onBack)178190                 }179191             }180192             Spacer(Modifier.height(PeachSpacing.md))@@ -243,6 +255,7 @@243255 }244256 245257 private fun failureCopy(reason: String): String = when (reason) {258+    "ADOPT_PRO_REQUIRED" -> "Peach Pro is required on at least one paired device."246259     "vault_locked" -> "Unlock your vault first — pairing shares the key that protects it."247260     "no_local_network" -> "This phone isn't on a Wi-Fi network, so there's no address to hand out."248261     "sas_mismatch" -> "The codes didn't match, so nothing was saved. That's the right outcome if you didn't expect a connection."

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+10 −0
@@ -167,6 +167,16 @@167167             override suspend fun license(): String? =168168                 license.getLicenseKey()?.takeIf(license::validateLicense)169169 170+            override suspend fun applyLicense(licenseKey: String) {171+                if (!license.validateLicense(licenseKey)) {172+                    throw IllegalStateException("license_transfer_failed")173+                }174+                license.storeLicense(licenseKey)175+                if (!license.isProActive()) {176+                    throw IllegalStateException("license_transfer_failed")177+                }178+            }179+170180             // Opening this source screen while unlocked is the source-side171181             // consent. The scanning device confirms the SAS before any grant.172182             override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true

view file

app/src/main/java/com/peachpasswords/ui/pairing/PairingProRequired.kt+137 −0
@@ -0,0 +1,137 @@1+package com.peachpasswords.ui.pairing2+3+import android.content.Intent4+import android.net.Uri5+import androidx.compose.foundation.layout.Column6+import androidx.compose.foundation.layout.Row7+import androidx.compose.foundation.layout.Spacer8+import androidx.compose.foundation.layout.fillMaxWidth9+import androidx.compose.foundation.layout.height10+import androidx.compose.foundation.layout.padding11+import androidx.compose.foundation.shape.RoundedCornerShape12+import androidx.compose.material3.HorizontalDivider13+import androidx.compose.material3.Surface14+import androidx.compose.material3.Text15+import androidx.compose.runtime.Composable16+import androidx.compose.ui.Modifier17+import androidx.compose.ui.platform.LocalContext18+import androidx.compose.ui.semantics.heading19+import androidx.compose.ui.semantics.semantics20+import androidx.compose.ui.text.font.FontStyle21+import androidx.compose.ui.unit.dp22+import androidx.compose.ui.unit.em23+import androidx.compose.ui.unit.sp24+import com.peachpasswords.ui.theme.LocalPeachAccent25+import com.peachpasswords.ui.theme.PeachSpacing26+import com.peachpasswords.ui.theme.PeachType27+import com.peachpasswords.ui.theme.peachColors28+29+internal const val PEACH_PRO_PURCHASE_URL =30+    "https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"31+32+private val pairingProFeatures = listOf(33+    "01" to ("Peach Sync" to "Encrypted sync across every paired device"),34+    "02" to ("One-time purchase" to "$39 once · yours forever"),35+    "03" to ("One shared key" to "Pro transfers during authenticated pairing"),36+)37+38+/**39+ * Terminal pairing fallback derived from Settings' Peach Pro card. A tonal40+ * Surface provides the MD3 container while Peach typography and spacing keep41+ * it visually native to the rest of the app.42+ */43+@Composable44+internal fun PairingProRequired(45+    onRetry: () -> Unit,46+    onOpenPro: (() -> Unit)? = null,47+) {48+    val context = LocalContext.current49+    val accent = LocalPeachAccent.current50+51+    Surface(52+        modifier = Modifier.fillMaxWidth(),53+        shape = RoundedCornerShape(24.dp),54+        color = peachColors.SurfaceElevated,55+        tonalElevation = 3.dp,56+    ) {57+        Column(Modifier.padding(PeachSpacing.lg)) {58+            Text(59+                "PEACH PRO · SYNC",60+                color = accent,61+                fontFamily = PeachType.Mono,62+                fontSize = 9.sp,63+                letterSpacing = 0.18.em,64+            )65+            Spacer(Modifier.height(PeachSpacing.sm))66+            Text(67+                "Pair freely. Sync with Pro.",68+                color = peachColors.T0,69+                fontFamily = PeachType.Display,70+                fontStyle = FontStyle.Italic,71+                fontSize = 28.sp,72+                modifier = Modifier.semantics { heading() },73+            )74+            Spacer(Modifier.height(PeachSpacing.sm))75+            Text(76+                "Pairing can start from any device, but at least one of these two " +77+                    "devices needs a valid Peach Pro license before pairing can finish. " +78+                    "No vault or device record was added.",79+                color = peachColors.T1,80+                fontFamily = PeachType.Body,81+                fontSize = 13.sp,82+                lineHeight = 19.sp,83+            )84+            Spacer(Modifier.height(PeachSpacing.md))85+86+            pairingProFeatures.forEach { (number, feature) ->87+                HorizontalDivider(color = peachColors.T3.copy(alpha = 0.55f))88+                Row(89+                    Modifier90+                        .fillMaxWidth()91+                        .padding(vertical = PeachSpacing.sm),92+                ) {93+                    Text(94+                        number,95+                        color = accent,96+                        fontFamily = PeachType.Mono,97+                        fontSize = 10.sp,98+                        modifier = Modifier.padding(end = PeachSpacing.md),99+                    )100+                    Column {101+                        Text(102+                            feature.first,103+                            color = peachColors.T0,104+                            fontFamily = PeachType.Body,105+                            fontSize = 14.sp,106+                        )107+                        Text(108+                            feature.second,109+                            color = peachColors.T2,110+                            fontFamily = PeachType.Body,111+                            fontSize = 11.sp,112+                        )113+                    }114+                }115+            }116+117+            Spacer(Modifier.height(PeachSpacing.md))118+            PillButton(119+                if (onOpenPro == null) "Get Peach Pro" else "Open Peach Pro",120+                accent,121+                filled = true,122+            ) {123+                if (onOpenPro != null) {124+                    onOpenPro()125+                } else {126+                    runCatching {127+                        context.startActivity(128+                            Intent(Intent.ACTION_VIEW, Uri.parse(PEACH_PRO_PURCHASE_URL)),129+                        )130+                    }131+                }132+            }133+            Spacer(Modifier.height(PeachSpacing.xs))134+            PillButton("Try another device", peachColors.T2, filled = false, onClick = onRetry)135+        }136+    }137+}

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionHostTest.kt+2 −0
@@ -109,6 +109,7 @@109109                     override val sourceIdentity = sourceIdentity110110                     override suspend fun rootKeyHex() = Rendezvous.bytesToHex(sourceRoot)111111                     override suspend fun vaultId() = computeVaultId(sourceRoot)112+                    override suspend fun license() = "PPK-test"112113                     override suspend fun onConsent(request: AdoptionFrame.Request) = true113114                     override suspend fun upsertPairedDevice(device: PairedDevice) {114115                         sourceStored = device@@ -234,6 +235,7 @@234235                     override val sourceIdentity = sourceIdentity235236                     override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)236237                     override suspend fun vaultId() = computeVaultId(root)238+                    override suspend fun license() = "PPK-test"237239                     override suspend fun onConsent(request: AdoptionFrame.Request) = true238240                     override suspend fun upsertPairedDevice(device: PairedDevice) {239241                         sourceStored = device

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionInitiatorSourceTest.kt+2 −1
@@ -73,6 +73,7 @@7373                     override val sourceIdentity = phoneIdentity7474                     override suspend fun rootKeyHex() = Rendezvous.bytesToHex(root)7575                     override suspend fun vaultId() = computeVaultId(root)76+                    override suspend fun license() = "PPK-test"7677                     override suspend fun onConsent(request: AdoptionFrame.Request) = true7778                     override suspend fun upsertPairedDevice(device: PairedDevice) {7879                         phoneStored = device@@ -283,4 +284,4 @@283284             )284285         }285286     }286-}\ No newline at end of file287+}

view file

app/src/test/java/com/peachpasswords/sync/VaultAdoptTest.kt+79 −3
@@ -57,6 +57,7 @@5757         private val createThrows: Boolean = false,5858         private val derived: LocalDeviceIdentity? = null,5959         private val sasConfirmed: Boolean = true,60+        private val localLicense: String? = "PPK-test",6061     ) : AdoptionReceiverDeps {6162         var createCalledWith: String? = null6263         var licenseApplied: String? = null@@ -76,6 +77,7 @@7677         }7778         override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }7879         override suspend fun applyLicense(license: String) { licenseApplied = license }80+        override suspend fun license(): String? = localLicense7981         override fun onError(error: String) { errors += error }8082     }8183 @@ -83,10 +85,12 @@8385         override val sourceIdentity: LocalDeviceIdentity,8486         private val rootKeyHex: String,8587         private val vaultId: String,86-        private val license: String? = null,88+        license: String? = "PPK-test",8789         private val consent: Boolean = true,8890     ) : AdoptionSourceDeps {91+        private var storedLicense: String? = license8992         val upserted = mutableListOf<PairedDevice>()93+        var licenseApplied: String? = null9094         var consentAsked = false9195         var rootRequested = false9296         override suspend fun rootKeyHex(): String {@@ -94,7 +98,11 @@9498             return rootKeyHex9599         }96100         override suspend fun vaultId(): String = vaultId97-        override suspend fun license(): String? = license101+        override suspend fun license(): String? = storedLicense102+        override suspend fun applyLicense(license: String) {103+            licenseApplied = license104+            storedLicense = license105+        }98106         override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {99107             consentAsked = true100108             return consent@@ -107,7 +115,13 @@107115         val rootKeyHex = "11".repeat(32)108116         val vaultId = vaultIdFor(rootKeyHex)109117         val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)110-        val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity)118+        val receiver = RecorderReceiver(119+            "Receiver",120+            DeviceType.ANDROID,121+            vaultId,122+            derived = receiverIdentity,123+            localLicense = null,124+        )111125         val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")112126 113127         val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }@@ -124,6 +138,68 @@124138         assertEquals("source-id", receiver.upserted.single().deviceId)125139         assertEquals("receiver-id", source.upserted.single().deviceId)126140         assertEquals("android", source.upserted.single().deviceType)141+    }142+143+    @Test fun rejects_without_creating_or_pairing_when_neither_peer_has_pro() = runBlocking {144+        val (receiverCh, sourceCh) = memoryPair()145+        val rootKeyHex = "12".repeat(32)146+        val vaultId = vaultIdFor(rootKeyHex)147+        val receiver = RecorderReceiver(148+            "Free receiver",149+            DeviceType.ANDROID,150+            vaultId,151+            localLicense = null,152+        )153+        val source = RecorderSource(154+            identity("free-source"),155+            rootKeyHex,156+            vaultId,157+            license = null,158+        )159+160+        val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }161+        val sourceJob = async {162+            val request = receiveFrame(sourceCh) as AdoptionFrame.Request163+            runCatching { grantAdoption(sourceCh, request, source) { 0L } }164+        }165+166+        assertNull(receiverJob.await())167+        val sourceOutcome = sourceJob.await()168+        assertTrue(sourceOutcome.isFailure)169+        assertEquals("ADOPT_PRO_REQUIRED", sourceOutcome.exceptionOrNull()?.message)170+        assertEquals(listOf("ADOPT_PRO_REQUIRED"), receiver.errors)171+        assertNull(receiver.createCalledWith)172+        assertTrue(receiver.upserted.isEmpty())173+        assertTrue(source.upserted.isEmpty())174+    }175+176+    @Test fun receiver_pro_license_is_transferred_to_a_free_source() = runBlocking {177+        val (receiverCh, sourceCh) = memoryPair()178+        val rootKeyHex = "13".repeat(32)179+        val vaultId = vaultIdFor(rootKeyHex)180+        val receiver = RecorderReceiver(181+            "Pro receiver",182+            DeviceType.ANDROID,183+            vaultId,184+            derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),185+            localLicense = "PPK-receiver",186+        )187+        val source = RecorderSource(188+            identity("free-source"),189+            rootKeyHex,190+            vaultId,191+            license = null,192+        )193+194+        val receiverJob = async { requestAdoption(receiverCh, receiver) { 1L } }195+        val sourceJob = async {196+            val request = receiveFrame(sourceCh) as AdoptionFrame.Request197+            grantAdoption(sourceCh, request, source) { 2L }198+        }199+200+        assertEquals("free-source-id", receiverJob.await()?.deviceId)201+        assertEquals("receiver-id", sourceJob.await()?.deviceId)202+        assertEquals("PPK-receiver", source.licenseApplied)127203     }128204 129205     /**

view file