818bd9bb6b50
fix: Enforce Pro entitlement during pairing (v2.8.1)
- time
- 2026-07-29 00:22 UTC · 2 months ago
- machine
- koh2
- parent
- 234156b10775
↓ peach-android-818bd9bb.tar.gzatlas at this save
files changed13 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 −3app/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 {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)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+}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,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(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" ->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 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."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 = trueapp/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+}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 = deviceapp/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+}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 /**