package com.peachpasswords.sync import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton /** * Sync is exactly three named choices (docs/sync-lan-retirement.md, mirroring * the browser's `sync-settings.ts`): * * - [PUBLIC] — Peach's relay. The default, zero setup. * - [PRIVATE] — a self-hosted peach-relay this mesh points at. * - [OFF] — no sync; the vault moves by Codex/CSV export instead. * * LAN peer-to-peer sync is retired, not deprecated: there is no fourth mode and * no "local network only" opt-out. */ enum class SyncMode { PUBLIC, PRIVATE, OFF, ; fun wire(): String = when (this) { PUBLIC -> "public" PRIVATE -> "private" OFF -> "off" } companion object { fun parse(raw: String?): SyncMode? = when (raw) { "public" -> PUBLIC "private" -> PRIVATE "off" -> OFF else -> null } } } /** * The relay to dial, or why there is none. Single gate for every sync and * pairing entry point, so a new caller cannot forget one of the two ways sync * can be unusable — and so a URL only ever reaches the wire out of [Dial]. */ sealed interface RelayDecision { @JvmInline value class Dial(val relayUrl: String) : RelayDecision data class Refuse(val reason: SyncOutcomeReason) : RelayDecision } /** * [SyncMode.PRIVATE] chosen but no relay entered yet. The stored URL still * reads as the public default in that state, so syncing would quietly put this * phone on Peach's relay under a "Private relay" label — the exact trap the * retired `lanOnly` flag set. Every entry point fails closed on it instead. */ fun isPrivateRelayUnconfigured(mode: SyncMode, storedRelayUrl: String): Boolean = mode == SyncMode.PRIVATE && storedRelayUrl == Rendezvous.DEFAULT_RELAY_URL /** * A custom URL implies [SyncMode.PRIVATE]; Peach's own relay stays * [SyncMode.PUBLIC]. Adoption hands the source's relay over the same path, so * labelling that "private" would strand every device that joined over the * public relay in the unconfigured state below. */ fun modeForRelayUrl(normalizedRelayUrl: String): SyncMode = if (normalizedRelayUrl == Rendezvous.DEFAULT_RELAY_URL) SyncMode.PUBLIC else SyncMode.PRIVATE /** * The private relay as the user should see it: blank when none is set. Never * Peach's own URL wearing a private label — an empty field is the honest * representation of "not configured". */ fun privateRelayUrlOf(storedRelayUrl: String): String = storedRelayUrl.takeIf { it != Rendezvous.DEFAULT_RELAY_URL }.orEmpty() /** The editable form always begins with the only accepted secure scheme. */ fun privateRelayDraftOf(configuredPrivateUrl: String): String = configuredPrivateUrl.ifBlank { "wss://" } fun relayInForce(mode: SyncMode, storedRelayUrl: String): RelayDecision = when { mode == SyncMode.OFF -> RelayDecision.Refuse(SyncOutcomeReason.SYNC_DISABLED) isPrivateRelayUnconfigured(mode, storedRelayUrl) -> RelayDecision.Refuse(SyncOutcomeReason.PRIVATE_RELAY_UNSET) mode == SyncMode.PUBLIC -> RelayDecision.Dial(Rendezvous.DEFAULT_RELAY_URL) else -> RelayDecision.Dial(storedRelayUrl) } /** * This handset's sync configuration. Plain SharedPreferences rather than the * encrypted store: none of it is a secret, and it must not travel to other * devices the way a vault setting would. */ @Singleton class RelaySettingsStore @Inject constructor( @ApplicationContext context: Context, private val devices: PairedDeviceStore, ) { private val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) fun syncMode(): SyncMode { SyncMode.parse(prefs.getString(KEY_MODE, null))?.let { return it } // Migration. Someone who set lanOnly chose "no relay involved", and LAN // is now gone, so the honest landing spot is OFF rather than silently // enrolling them in a relay they opted out of. Everyone else lands on // public/private by whether they had configured a custom URL. return when { prefs.getBoolean(LEGACY_KEY_LAN_ONLY, false) -> SyncMode.OFF storedRelayUrl() != Rendezvous.DEFAULT_RELAY_URL -> SyncMode.PRIVATE else -> SyncMode.PUBLIC } } /** * The relay is mesh state: every paired device must agree on it or a peer * keeps dialling one we no longer use, and reconcile prefers the pair's own * URL over this setting. So the mode change reaches the mesh before it is * stored — the browser's `setSyncMode` orders it the same way. */ fun setSyncMode(mode: SyncMode) { devices.propagateRelaySetting(mode, storedRelayUrl()) prefs.edit().putString(KEY_MODE, mode.wire()).apply() } /** The relay to dial, or the reason there is none. */ fun relayInForce(): RelayDecision = relayInForce(syncMode(), storedRelayUrl()) /** Blank while no private relay is set. See [privateRelayUrlOf]. */ fun privateRelayUrl(): String = privateRelayUrlOf(storedRelayUrl()) /** * Point this mesh at a relay by URL. A custom URL implies * [SyncMode.PRIVATE] — offering one and not switching to it would be a * trap. Peach's own relay stays [SyncMode.PUBLIC]: adoption hands the * source's relay over this same path, so calling that "private" would * mislabel every device that joined over the public relay and strand it in * the unconfigured-private state above. */ fun setRelayUrl(input: String): String { val normalized = Rendezvous.normalizeRelayUrl(input) devices.propagateRelaySetting(modeForRelayUrl(normalized), normalized) prefs.edit() .putString(KEY_URL, normalized) .putString(KEY_MODE, modeForRelayUrl(normalized).wire()) .apply() return normalized } fun reset() { prefs.edit().clear().apply() } private fun storedRelayUrl(): String = prefs.getString(KEY_URL, null) ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() } ?: Rendezvous.DEFAULT_RELAY_URL companion object { const val PREFS_FILE = "peach_relay_settings" const val KEY_URL = "relay_url" const val KEY_MODE = "sync_mode" /** Retired `lanOnly` flag, read once for migration and never written. */ const val LEGACY_KEY_LAN_ONLY = "lan_only" fun readRelayUrl(context: Context): String = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) .getString(KEY_URL, null) ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() } ?: Rendezvous.DEFAULT_RELAY_URL } }