RelaySettingsStore.kt

5.8 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.PRIVATE6970fun relayInForce(mode: SyncMode, storedRelayUrl: String): RelayDecision = when {71    mode == SyncMode.OFF -> RelayDecision.Refuse(SyncOutcomeReason.SYNC_DISABLED)72    isPrivateRelayUnconfigured(mode, storedRelayUrl) ->73        RelayDecision.Refuse(SyncOutcomeReason.PRIVATE_RELAY_UNSET)74    mode == SyncMode.PUBLIC -> RelayDecision.Dial(Rendezvous.DEFAULT_RELAY_URL)75    else -> RelayDecision.Dial(storedRelayUrl)76}7778/**79 * This handset's sync configuration. Plain SharedPreferences rather than the80 * encrypted store: none of it is a secret, and it must not travel to other81 * devices the way a vault setting would.82 */83@Singleton84class RelaySettingsStore @Inject constructor(85    @ApplicationContext context: Context,86) {87    private val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)8889    fun syncMode(): SyncMode {90        SyncMode.parse(prefs.getString(KEY_MODE, null))?.let { return it }91        // Migration. Someone who set lanOnly chose "no relay involved", and LAN92        // is now gone, so the honest landing spot is OFF rather than silently93        // enrolling them in a relay they opted out of. Everyone else lands on94        // public/private by whether they had configured a custom URL.95        return when {96            prefs.getBoolean(LEGACY_KEY_LAN_ONLY, false) -> SyncMode.OFF97            storedRelayUrl() != Rendezvous.DEFAULT_RELAY_URL -> SyncMode.PRIVATE98            else -> SyncMode.PUBLIC99        }100    }101102    fun setSyncMode(mode: SyncMode) {103        prefs.edit().putString(KEY_MODE, mode.wire()).apply()104    }105106    /** The relay to dial, or the reason there is none. */107    fun relayInForce(): RelayDecision = relayInForce(syncMode(), storedRelayUrl())108109    /**110     * The private relay as the user should see it: blank when none is set.111     * Never Peach's own URL wearing a private label — an empty field is the112     * honest representation of "not configured".113     */114    fun privateRelayUrl(): String =115        storedRelayUrl().takeIf { it != Rendezvous.DEFAULT_RELAY_URL }.orEmpty()116117    /**118     * Point this mesh at a relay by URL. A custom URL implies119     * [SyncMode.PRIVATE] — offering one and not switching to it would be a120     * trap. Peach's own relay stays [SyncMode.PUBLIC]: adoption hands the121     * source's relay over this same path, so calling that "private" would122     * mislabel every device that joined over the public relay and strand it in123     * the unconfigured-private state above.124     */125    fun setRelayUrl(input: String): String {126        val normalized = Rendezvous.normalizeRelayUrl(input)127        prefs.edit()128            .putString(KEY_URL, normalized)129            .putString(KEY_MODE, modeForRelayUrl(normalized).wire())130            .apply()131        return normalized132    }133134    private fun storedRelayUrl(): String = prefs.getString(KEY_URL, null)135        ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() }136        ?: Rendezvous.DEFAULT_RELAY_URL137138    companion object {139        const val PREFS_FILE = "peach_relay_settings"140        const val KEY_URL = "relay_url"141        const val KEY_MODE = "sync_mode"142        /** Retired `lanOnly` flag, read once for migration and never written. */143        const val LEGACY_KEY_LAN_ONLY = "lan_only"144145        fun readRelayUrl(context: Context): String =146            context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)147                .getString(KEY_URL, null)148                ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() }149                ?: Rendezvous.DEFAULT_RELAY_URL150    }151}