RelaySettingsStore.kt

6.7 KB · raw

1package com.peachpasswords.sync23import android.content.Context4import dagger.hilt.android.qualifiers.ApplicationContext5import javax.inject.Inject6import javax.inject.Singleton78/**9 * Sync is exactly three named choices (docs/sync-lan-retirement.md, mirroring10 * the browser's `sync-settings.ts`):11 *12 *  - [PUBLIC]  — Peach's relay. The default, zero setup.13 *  - [PRIVATE] — a self-hosted peach-relay this mesh points at.14 *  - [OFF]     — no sync; the vault moves by Codex/CSV export instead.15 *16 * LAN peer-to-peer sync is retired, not deprecated: there is no fourth mode and17 * no "local network only" opt-out.18 */19enum class SyncMode {20    PUBLIC,21    PRIVATE,22    OFF,23    ;2425    fun wire(): String = when (this) {26        PUBLIC -> "public"27        PRIVATE -> "private"28        OFF -> "off"29    }3031    companion object {32        fun parse(raw: String?): SyncMode? = when (raw) {33            "public" -> PUBLIC34            "private" -> PRIVATE35            "off" -> OFF36            else -> null37        }38    }39}4041/**42 * The relay to dial, or why there is none. Single gate for every sync and43 * pairing entry point, so a new caller cannot forget one of the two ways sync44 * can be unusable — and so a URL only ever reaches the wire out of [Dial].45 */46sealed interface RelayDecision {47    @JvmInline48    value class Dial(val relayUrl: String) : RelayDecision49    data class Refuse(val reason: SyncOutcomeReason) : RelayDecision50}5152/**53 * [SyncMode.PRIVATE] chosen but no relay entered yet. The stored URL still54 * reads as the public default in that state, so syncing would quietly put this55 * phone on Peach's relay under a "Private relay" label — the exact trap the56 * retired `lanOnly` flag set. Every entry point fails closed on it instead.57 */58fun isPrivateRelayUnconfigured(mode: SyncMode, storedRelayUrl: String): Boolean =59    mode == SyncMode.PRIVATE && storedRelayUrl == Rendezvous.DEFAULT_RELAY_URL6061/**62 * A custom URL implies [SyncMode.PRIVATE]; Peach's own relay stays63 * [SyncMode.PUBLIC]. Adoption hands the source's relay over the same path, so64 * labelling that "private" would strand every device that joined over the65 * public relay in the unconfigured state below.66 */67fun modeForRelayUrl(normalizedRelayUrl: String): SyncMode =68    if (normalizedRelayUrl == Rendezvous.DEFAULT_RELAY_URL) SyncMode.PUBLIC else SyncMode.PRIVATE6970/**71 * The private relay as the user should see it: blank when none is set. Never72 * Peach's own URL wearing a private label — an empty field is the honest73 * representation of "not configured".74 */75fun privateRelayUrlOf(storedRelayUrl: String): String =76    storedRelayUrl.takeIf { it != Rendezvous.DEFAULT_RELAY_URL }.orEmpty()7778/** The editable form always begins with the only accepted secure scheme. */79fun privateRelayDraftOf(configuredPrivateUrl: String): String =80    configuredPrivateUrl.ifBlank { "wss://" }8182fun relayInForce(mode: SyncMode, storedRelayUrl: String): RelayDecision = when {83    mode == SyncMode.OFF -> RelayDecision.Refuse(SyncOutcomeReason.SYNC_DISABLED)84    isPrivateRelayUnconfigured(mode, storedRelayUrl) ->85        RelayDecision.Refuse(SyncOutcomeReason.PRIVATE_RELAY_UNSET)86    mode == SyncMode.PUBLIC -> RelayDecision.Dial(Rendezvous.DEFAULT_RELAY_URL)87    else -> RelayDecision.Dial(storedRelayUrl)88}8990/**91 * This handset's sync configuration. Plain SharedPreferences rather than the92 * encrypted store: none of it is a secret, and it must not travel to other93 * devices the way a vault setting would.94 */95@Singleton96class RelaySettingsStore @Inject constructor(97    @ApplicationContext context: Context,98    private val devices: PairedDeviceStore,99) {100    private val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)101102    fun syncMode(): SyncMode {103        SyncMode.parse(prefs.getString(KEY_MODE, null))?.let { return it }104        // Migration. Someone who set lanOnly chose "no relay involved", and LAN105        // is now gone, so the honest landing spot is OFF rather than silently106        // enrolling them in a relay they opted out of. Everyone else lands on107        // public/private by whether they had configured a custom URL.108        return when {109            prefs.getBoolean(LEGACY_KEY_LAN_ONLY, false) -> SyncMode.OFF110            storedRelayUrl() != Rendezvous.DEFAULT_RELAY_URL -> SyncMode.PRIVATE111            else -> SyncMode.PUBLIC112        }113    }114115    /**116     * The relay is mesh state: every paired device must agree on it or a peer117     * keeps dialling one we no longer use, and reconcile prefers the pair's own118     * URL over this setting. So the mode change reaches the mesh before it is119     * stored — the browser's `setSyncMode` orders it the same way.120     */121    fun setSyncMode(mode: SyncMode) {122        devices.propagateRelaySetting(mode, storedRelayUrl())123        prefs.edit().putString(KEY_MODE, mode.wire()).apply()124    }125126    /** The relay to dial, or the reason there is none. */127    fun relayInForce(): RelayDecision = relayInForce(syncMode(), storedRelayUrl())128129    /** Blank while no private relay is set. See [privateRelayUrlOf]. */130    fun privateRelayUrl(): String = privateRelayUrlOf(storedRelayUrl())131132    /**133     * Point this mesh at a relay by URL. A custom URL implies134     * [SyncMode.PRIVATE] — offering one and not switching to it would be a135     * trap. Peach's own relay stays [SyncMode.PUBLIC]: adoption hands the136     * source's relay over this same path, so calling that "private" would137     * mislabel every device that joined over the public relay and strand it in138     * the unconfigured-private state above.139     */140    fun setRelayUrl(input: String): String {141        val normalized = Rendezvous.normalizeRelayUrl(input)142        devices.propagateRelaySetting(modeForRelayUrl(normalized), normalized)143        prefs.edit()144            .putString(KEY_URL, normalized)145            .putString(KEY_MODE, modeForRelayUrl(normalized).wire())146            .apply()147        return normalized148    }149150    fun reset() {151        prefs.edit().clear().apply()152    }153154    private fun storedRelayUrl(): String = prefs.getString(KEY_URL, null)155        ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() }156        ?: Rendezvous.DEFAULT_RELAY_URL157158    companion object {159        const val PREFS_FILE = "peach_relay_settings"160        const val KEY_URL = "relay_url"161        const val KEY_MODE = "sync_mode"162        /** Retired `lanOnly` flag, read once for migration and never written. */163        const val LEGACY_KEY_LAN_ONLY = "lan_only"164165        fun readRelayUrl(context: Context): String =166            context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)167                .getString(KEY_URL, null)168                ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() }169                ?: Rendezvous.DEFAULT_RELAY_URL170    }171}