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 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 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 } } fun setSyncMode(mode: SyncMode) { prefs.edit().putString(KEY_MODE, mode.wire()).apply() } /** The relay to dial, or the reason there is none. */ fun relayInForce(): RelayDecision = relayInForce(syncMode(), storedRelayUrl()) /** * 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 privateRelayUrl(): String = storedRelayUrl().takeIf { it != Rendezvous.DEFAULT_RELAY_URL }.orEmpty() /** * 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) prefs.edit() .putString(KEY_URL, normalized) .putString(KEY_MODE, modeForRelayUrl(normalized).wire()) .apply() return normalized } 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 } }