commit 02aa4f43c1cb1022d1592379c00b86ea6b9af2b9
parent 18973e0aa62ae809b0495d5812b0c5ac8c7774ff
Author: triesap <tyson@radroots.org>
Date: Mon, 10 Aug 2026 21:25:38 +0000
mobile: apply persisted network settings to runtime
Diffstat:
4 files changed, 133 insertions(+), 10 deletions(-)
diff --git a/crates/mobile_core/src/runtime/product_surface/settings.rs b/crates/mobile_core/src/runtime/product_surface/settings.rs
@@ -803,6 +803,13 @@ impl MobileSettings {
self.local_storage = local_storage;
self
}
+
+ fn validate(&self) -> Result<(), SettingsError> {
+ if self.relays.environment() != self.blossom.environment() {
+ return Err(SettingsError::NetworkEnvironmentMismatch);
+ }
+ Ok(())
+ }
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -816,6 +823,7 @@ impl ReplaceMobileSettings {
if expected_revision == 0 || settings.revision != expected_revision {
return Err(SettingsError::RevisionConflict);
}
+ settings.validate()?;
Ok(Self {
expected_revision,
settings,
@@ -845,6 +853,8 @@ pub enum SettingsError {
InvalidBlossomEndpoint,
#[error("Blossom endpoint count is invalid")]
InvalidBlossomEndpointCount,
+ #[error("relay and Blossom network environments do not match")]
+ NetworkEnvironmentMismatch,
#[error("media cache byte quota is invalid")]
InvalidMediaCacheBytes,
#[error("media cache artifact quota is invalid")]
@@ -872,6 +882,7 @@ impl SettingsError {
Self::DuplicateRelayEndpoint => "duplicate_relay_endpoint",
Self::InvalidBlossomEndpoint => "invalid_blossom_endpoint",
Self::InvalidBlossomEndpointCount => "invalid_blossom_endpoint_count",
+ Self::NetworkEnvironmentMismatch => "network_environment_mismatch",
Self::InvalidMediaCacheBytes => "invalid_media_cache_bytes",
Self::InvalidMediaCacheArtifacts => "invalid_media_cache_artifacts",
Self::RevisionConflict => "settings_revision_conflict",
@@ -1129,14 +1140,16 @@ impl TryFrom<StoredSettingsV1> for MobileSettings {
if value.schema_version != MOBILE_SETTINGS_SCHEMA_VERSION || value.revision == 0 {
return Err(SettingsError::CorruptDocument);
}
- Ok(Self {
+ let settings = Self {
revision: value.revision,
identity: value.identity.try_into()?,
relays: value.relays.try_into()?,
blossom: value.blossom.try_into()?,
media_network: value.media_network,
local_storage: value.local_storage.try_into()?,
- })
+ };
+ settings.validate()?;
+ Ok(settings)
}
}
@@ -1147,7 +1160,7 @@ impl TryFrom<StoredSettingsV0> for MobileSettings {
if value.schema_version != 0 || value.revision == 0 {
return Err(SettingsError::CorruptDocument);
}
- Ok(Self {
+ let settings = Self {
revision: value.revision,
identity: value.identity.try_into()?,
relays: value.relays.try_into()?,
@@ -1161,7 +1174,9 @@ impl TryFrom<StoredSettingsV0> for MobileSettings {
value.media_cache_bytes,
value.media_cache_artifacts,
)?,
- })
+ };
+ settings.validate()?;
+ Ok(settings)
}
}
@@ -1503,6 +1518,15 @@ mod tests {
assert_eq!(conflict, SettingsError::RevisionConflict);
}
+ #[test]
+ fn settings_reject_mixed_network_environments() {
+ let settings = MobileSettings::default().with_blossom(BlossomPreferences::simulator_default());
+ assert_eq!(
+ ReplaceMobileSettings::new(settings.revision(), settings).unwrap_err(),
+ SettingsError::NetworkEnvironmentMismatch
+ );
+ }
+
#[tokio::test]
async fn atomic_identity_commands_persist_public_state_but_keep_unlock_process_local() {
let runtime = RadrootsRuntime::test_memory().unwrap();
diff --git a/crates/mobile_core/src/runtime/sdk.rs b/crates/mobile_core/src/runtime/sdk.rs
@@ -1,6 +1,7 @@
use radroots_sdk::capability::{Availability, Maturity};
use super::RadrootsRuntime;
+use super::product_surface::{BlossomPreferences, RelayPreferences};
use crate::RadrootsAppError;
#[derive(Clone, Debug)]
@@ -155,6 +156,19 @@ impl RadrootsRuntime {
.map_err(RadrootsAppError::from_sdk)
}
+ /// Installs the exact validated relay preferences persisted by the mobile product.
+ #[cfg(feature = "mobile-social")]
+ pub fn configure_relay_preferences(
+ &self,
+ preferences: &RelayPreferences,
+ ) -> Result<(), RadrootsAppError> {
+ self.configure_relay_profile(
+ preferences
+ .sdk_profile()
+ .map_err(|error| RadrootsAppError::runtime(error.code()))?,
+ )
+ }
+
/// Installs one canonical inert Blossom configuration without probing it.
#[cfg(feature = "mobile-social")]
pub fn configure_blossom(
@@ -187,6 +201,19 @@ impl RadrootsRuntime {
.map_err(RadrootsAppError::from_sdk)
}
+ /// Installs the exact validated Blossom preferences persisted by the mobile product.
+ #[cfg(feature = "mobile-social")]
+ pub fn configure_blossom_preferences(
+ &self,
+ preferences: &BlossomPreferences,
+ ) -> Result<(), RadrootsAppError> {
+ self.configure_blossom_profile(
+ preferences
+ .sdk_profile()
+ .map_err(|error| RadrootsAppError::runtime(error.code()))?,
+ )
+ }
+
/// Returns the configured adapter slot for Rust-owned media binding.
#[cfg(feature = "mobile-social")]
pub fn sdk_blossom_slot(
diff --git a/crates/mobile_ffi/src/runtime.rs b/crates/mobile_ffi/src/runtime.rs
@@ -843,6 +843,21 @@ impl RadrootsRuntime {
.map_err(Into::into)
}
+ pub async fn phase1_apply_settings_to_runtime(
+ &self,
+ ) -> Result<FfiMobileSettingsRecord, RadrootsAppError> {
+ let settings = self.inner.phase1_settings().await?;
+ self.inner
+ .configure_relay_preferences(settings.relays())
+ .map_err(RadrootsAppError::from)?;
+ self.inner
+ .configure_blossom_preferences(settings.blossom())
+ .map_err(RadrootsAppError::from)?;
+ self.subscriptions
+ .notify(FfiRuntimeChangeKind::Settings, None);
+ Ok((&settings).into())
+ }
+
pub async fn phase1_replace_settings(
&self,
input: FfiReplaceSettingsRecord,
diff --git a/crates/mobile_ffi/tests/runtime_delegation.rs b/crates/mobile_ffi/tests/runtime_delegation.rs
@@ -1,11 +1,14 @@
use radroots_mobile_ffi::{
- FfiAddCommandType, FfiAddDraftInput, FfiBlossomEndpointAuthority, FfiBlossomHostKind,
+ FfiAddCommandType, FfiAddDraftInput, FfiBlossomAuthorityPreference,
+ FfiBlossomEndpointAuthority, FfiBlossomHostKind, FfiBlossomPreferencesRecord,
FfiBlossomUploadIntent, FfiCancellationPolicy, FfiDraftKind, FfiIdentityCommandKind,
- FfiIdentityCommandRecord, FfiIdentityLockState, FfiLocalNetworkRecord, FfiOutboxState,
- FfiPreparedMediaInput, FfiProfileMetadataInputRecord, FfiQueuePolicyRecord,
- FfiRelayAccessRecord, FfiRelaySatisfaction, FfiRetractionDraftInput, FfiRevisionInputRecord,
- FfiRevisionPhase, FfiTodayCardType, FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION,
- RadrootsAppError,
+ FfiIdentityCommandRecord, FfiIdentityLockState, FfiLocalNetworkRecord,
+ FfiLocalStoragePolicyRecord, FfiMediaNetworkPolicyRecord, FfiMobileNetworkEnvironment,
+ FfiOutboxState, FfiPreparedMediaInput, FfiProfileMetadataInputRecord, FfiQueuePolicyRecord,
+ FfiRelayAccessPreference, FfiRelayAccessRecord, FfiRelayPreferenceRecord,
+ FfiRelayPreferencesRecord, FfiRelaySatisfaction, FfiReplaceSettingsRecord,
+ FfiRetractionDraftInput, FfiRevisionInputRecord, FfiRevisionPhase, FfiTodayCardType,
+ FfiTodayProjectionUpdate, MOBILE_FFI_SCHEMA_VERSION, RadrootsAppError,
};
mod support;
@@ -60,6 +63,60 @@ async fn native_boundary_delegates_the_complete_core_surface() {
.is_err()
);
+ let settings = runtime.phase1_settings().await.expect("settings");
+ runtime
+ .phase1_replace_settings(FfiReplaceSettingsRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ expected_revision: settings.revision,
+ relays: FfiRelayPreferencesRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ environment: FfiMobileNetworkEnvironment::Public,
+ endpoints: vec![
+ FfiRelayPreferenceRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ url: "wss://read.example".to_owned(),
+ access: FfiRelayAccessPreference::ReadOnly,
+ },
+ FfiRelayPreferenceRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ url: "wss://write.example".to_owned(),
+ access: FfiRelayAccessPreference::ReadWrite,
+ },
+ ],
+ },
+ blossom: FfiBlossomPreferencesRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ environment: FfiMobileNetworkEnvironment::Public,
+ authority: FfiBlossomAuthorityPreference::PublicWebPki,
+ primary_origin: "https://media.example".to_owned(),
+ fallback_origins: vec![],
+ },
+ media_network: FfiMediaNetworkPolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ allow_cellular_downloads: true,
+ allow_cellular_uploads: true,
+ allow_background_transfers: true,
+ },
+ local_storage: FfiLocalStoragePolicyRecord {
+ schema_version: MOBILE_FFI_SCHEMA_VERSION,
+ media_cache_bytes: 256 * 1024 * 1024,
+ media_cache_artifacts: 1024,
+ },
+ })
+ .await
+ .expect("replace settings");
+ runtime
+ .phase1_apply_settings_to_runtime()
+ .await
+ .expect("apply settings");
+ let applied = runtime
+ .sdk_relay_status()
+ .expect("relay status")
+ .expect("applied settings profile");
+ assert_eq!(applied.relays.len(), 2);
+ assert_eq!(applied.relays[0].access, FfiRelayAccessRecord::ReadOnly);
+ assert_eq!(applied.relays[1].access, FfiRelayAccessRecord::ReadWrite);
+
runtime
.configure_simulator_relays(vec!["ws://127.0.0.1:7447".to_owned()])
.expect("simulator relays");