From 102a26ee5463144c29932730dfb7ec55a19f8b48 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 5 May 2026 05:45:36 -0600 Subject: [PATCH 01/35] Add peer-share toggle to lantern-core (Share My Connection PR 3/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 3 of 4 implementing the lantern-side wiring for "Share My Connection". Bumps radiance to fisk/peer-localbackend tip so we can reference the new PeerShareEnabledKey setting; that bump is provisional and should be re-pinned to a release tag once radiance #460 merges. * lantern-core/core.go: new PeerShare interface (mirrors Ads / SmartRouting), embedded in Core. SetPeerShareEnabled patches PeerShareEnabledKey via the radiance ipc client; IsPeerShareEnabled reads the snapshot. * lantern-core/ffi/ffi.go: new //export setPeerProxyEnabled and //export isPeerProxyEnabled, mirroring setBlockAdsEnabled exactly. The Dart FFI binding name uses "PeerProxy" to match the existing user-facing naming in the lantern repo (vpn_setting.dart toggle was drafted as "Peer Proxy"). * lantern-core/mobile/mobile.go: SetPeerShareEnabled / IsPeerShareEnabled for the gomobile-bind surface so Android can toggle once Dart wires it up in PR 4. The lifecycle path: Dart toggle → setPeerProxyEnabled(enabled) → LanternCore.SetPeerShareEnabled → ipc.Client.PatchSettings({PeerShareEnabledKey: ...}) → radiance LocalBackend.PatchSettings dispatch → peer.Client.Start / Stop ffigen regen for the Dart bindings happens in PR 4 alongside the Dart wire-through and rollback logic. go test ./lantern-core/... and golangci-lint --new-from-rev=origin/main both clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- lantern-core/core.go | 16 ++++++++++++++++ lantern-core/ffi/ffi.go | 23 +++++++++++++++++++++++ lantern-core/mobile/mobile.go | 17 +++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/lantern-core/core.go b/lantern-core/core.go index 7b2cf30f21..4c4669e9f5 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -153,6 +153,11 @@ type SmartRouting interface { IsSmartRoutingEnabled() bool } +type PeerShare interface { + SetPeerShareEnabled(bool) error + IsPeerShareEnabled() bool +} + type VPN interface { ConnectVPN(tag string) error SelectServer(tag string) error @@ -169,6 +174,7 @@ type Core interface { SplitTunnel Ads SmartRouting + PeerShare VPN Client() *ipc.Client } @@ -454,6 +460,16 @@ func (lc *LanternCore) IsSmartRoutingEnabled() bool { return b } +func (lc *LanternCore) SetPeerShareEnabled(enabled bool) error { + _, err := lc.client.PatchSettings(lc.ctx, settings.Settings{settings.PeerShareEnabledKey: enabled}) + return err +} + +func (lc *LanternCore) IsPeerShareEnabled() bool { + b, _ := lc.settings()[settings.PeerShareEnabledKey].(bool) + return b +} + func (lc *LanternCore) IsTelemetryEnabled() bool { b, _ := lc.settings()[settings.TelemetryKey].(bool) return b diff --git a/lantern-core/ffi/ffi.go b/lantern-core/ffi/ffi.go index d10403cb72..ca67526fdb 100644 --- a/lantern-core/ffi/ffi.go +++ b/lantern-core/ffi/ffi.go @@ -1352,6 +1352,29 @@ func isSmartRoutingEnabled() C.int { return 0 } +//export setPeerProxyEnabled +func setPeerProxyEnabled(enabled C.int) *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + if err := c.SetPeerShareEnabled(enabled != 0); err != nil { + return SendError(err) + } + return C.CString("ok") + }) +} + +//export isPeerProxyEnabled +func isPeerProxyEnabled() C.int { + c, _ := requireCore() + if c != nil && c.IsPeerShareEnabled() { + return 1 + } + return 0 +} + //export getSplitTunnelState func getSplitTunnelState() *C.char { return runOnGoStack(func() *C.char { diff --git a/lantern-core/mobile/mobile.go b/lantern-core/mobile/mobile.go index 75c4fae397..c04c2929e0 100644 --- a/lantern-core/mobile/mobile.go +++ b/lantern-core/mobile/mobile.go @@ -203,6 +203,23 @@ func SetSmartRoutingEnabled(enabled bool) error { }) } +func SetPeerShareEnabled(enabled bool) error { + slog.Info("peer-share: SetPeerShareEnabled", "enabled", enabled) + return withCore(func(c lanterncore.Core) error { + return c.SetPeerShareEnabled(enabled) + }) +} + +func IsPeerShareEnabled() bool { + ok, err := withCoreR(func(c lanterncore.Core) (bool, error) { + return c.IsPeerShareEnabled(), nil + }) + if err != nil { + return false + } + return ok +} + func IsSmartRoutingEnabled() bool { ok, err := withCoreR(func(c lanterncore.Core) (bool, error) { return c.IsSmartRoutingEnabled(), nil From 118fa8a4d74213f4ba7ae13ae27a7b8a03e8b932 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 5 May 2026 06:06:10 -0600 Subject: [PATCH 02/35] Wire Share My Connection toggle in Dart UI (PR 4/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final PR in the four-PR stack. Stacks on lantern #8729 (FFI exports); combined with radiance #458 / #460 / lantern-cloud #2678-#2681 this ships a feature-complete Phase 1 of "Share My Connection" for desktop (macOS + Linux + Windows). * lantern_generated_bindings.dart: add setPeerProxyEnabled + isPeerProxyEnabled. Manually inserted to match the existing pattern rather than regenerating the whole file (a local ffigen run from the macOS header would drop ~5K lines of Windows-only declarations the upstream generator emits). * LanternCoreService / LanternFFIService / LanternPlatformService / LanternService: add setPeerProxyEnabled / isPeerProxyEnabled across all four service layers, mirroring the setBlockAdsEnabled pattern. FFI path on isFFISupported platforms (Windows + Linux), MethodChannel fallback on macOS / mobile. * RadianceSettingsState: new peerProxy bool field with copyWith and equality. * RadianceSettings notifier: new setPeerProxy method (pessimistic — call FFI, log on failure, update state on success — matching setBlockAds). _refresh now reads peerProxy alongside the others. * vpn_setting.dart: SwitchButton tile gated to PlatformUtils.isDesktop with i18n strings share_my_connection / share_my_connection_subtitle in en.po. Other locales will pick up via the standard translation flow. Lifecycle end-to-end: Dart toggle → RadianceSettings.setPeerProxy(bool) → LanternService.setPeerProxyEnabled → FFI: setPeerProxyEnabled(int) -> *char → Core.SetPeerShareEnabled(bool) → ipc.Client.PatchSettings({PeerShareEnabledKey: ...}) → radiance LocalBackend.PatchSettings dispatch → peer.Client.Start / Stop → UPnP MapPort + register + sing-box samizdat inbound + heartbeat flutter analyze: clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- assets/locales/en.po | 6 ++++ lib/core/models/radiance_settings_state.dart | 9 +++-- .../provider/radiance_settings_providers.dart | 14 ++++++++ lib/features/setting/vpn_setting.dart | 33 +++++++++++++++++++ lib/lantern/lantern_core_service.dart | 4 +++ lib/lantern/lantern_ffi_service.dart | 28 ++++++++++++++++ lib/lantern/lantern_generated_bindings.dart | 20 +++++++++++ lib/lantern/lantern_platform_service.dart | 24 ++++++++++++++ lib/lantern/lantern_service.dart | 16 +++++++++ 9 files changed, 152 insertions(+), 2 deletions(-) diff --git a/assets/locales/en.po b/assets/locales/en.po index 48aee83834..5453241b12 100644 --- a/assets/locales/en.po +++ b/assets/locales/en.po @@ -551,6 +551,12 @@ msgstr "Block Ads" msgid "only_active" msgstr "Only active when VPN is connected" +msgid "share_my_connection" +msgstr "Share My Connection" + +msgid "share_my_connection_subtitle" +msgstr "Let other Lantern users route through your connection to bypass censorship." + msgid "vpn_connected" msgstr "Lantern is now connected." diff --git a/lib/core/models/radiance_settings_state.dart b/lib/core/models/radiance_settings_state.dart index 70323e0a13..28026ff95a 100644 --- a/lib/core/models/radiance_settings_state.dart +++ b/lib/core/models/radiance_settings_state.dart @@ -12,12 +12,14 @@ class RadianceSettingsState { final RoutingMode routingMode; final bool splitTunneling; final bool telemetry; + final bool peerProxy; const RadianceSettingsState({ this.blockAds = false, this.routingMode = RoutingMode.full, this.splitTunneling = false, this.telemetry = false, + this.peerProxy = false, }); RadianceSettingsState copyWith({ @@ -25,12 +27,14 @@ class RadianceSettingsState { RoutingMode? routingMode, bool? splitTunneling, bool? telemetry, + bool? peerProxy, }) { return RadianceSettingsState( blockAds: blockAds ?? this.blockAds, routingMode: routingMode ?? this.routingMode, splitTunneling: splitTunneling ?? this.splitTunneling, telemetry: telemetry ?? this.telemetry, + peerProxy: peerProxy ?? this.peerProxy, ); } @@ -41,9 +45,10 @@ class RadianceSettingsState { blockAds == other.blockAds && routingMode == other.routingMode && splitTunneling == other.splitTunneling && - telemetry == other.telemetry; + telemetry == other.telemetry && + peerProxy == other.peerProxy; @override int get hashCode => - Object.hash(blockAds, routingMode, splitTunneling, telemetry); + Object.hash(blockAds, routingMode, splitTunneling, telemetry, peerProxy); } diff --git a/lib/features/home/provider/radiance_settings_providers.dart b/lib/features/home/provider/radiance_settings_providers.dart index ae9cee0f6d..89ececbb66 100644 --- a/lib/features/home/provider/radiance_settings_providers.dart +++ b/lib/features/home/provider/radiance_settings_providers.dart @@ -29,16 +29,19 @@ class RadianceSettings extends _$RadianceSettings { final routingF = svc.isSmartRoutingEnabled(); final telemetryF = svc.isTelemetryEnabled(); final splitF = PlatformUtils.isIOS ? null : svc.isSplitTunnelingEnabled(); + final peerProxyF = svc.isPeerProxyEnabled(); final results = await Future.wait([ blockAdsF, routingF, telemetryF, ?splitF, + peerProxyF, ]); if (!ref.mounted) return; const defaults = RadianceSettingsState(); + final peerIdx = splitF == null ? 3 : 4; state = RadianceSettingsState( blockAds: results[0].fold((_) => defaults.blockAds, (v) => v), routingMode: results[1].fold( @@ -49,6 +52,7 @@ class RadianceSettings extends _$RadianceSettings { splitTunneling: splitF == null ? defaults.splitTunneling : results[3].fold((_) => defaults.splitTunneling, (v) => v), + peerProxy: results[peerIdx].fold((_) => defaults.peerProxy, (v) => v), ); } @@ -97,6 +101,16 @@ class RadianceSettings extends _$RadianceSettings { (_) => state = state.copyWith(telemetry: consent), ); } + + Future setPeerProxy(bool value) async { + final svc = ref.read(lanternServiceProvider); + final result = await svc.setPeerProxyEnabled(value); + if (!ref.mounted) return; + result.fold( + (err) => appLogger.error('setPeerProxyEnabled failed: ${err.error}'), + (_) => state = state.copyWith(peerProxy: value), + ); + } } /// Fetches whether user logged in via OAuth from radiance. diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index 8c108473a9..ec4d3920a5 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -35,6 +35,9 @@ class VPNSetting extends HookConsumerWidget { final telemetryConsent = ref.watch( radianceSettingsProvider.select((s) => s.telemetry), ); + final peerProxy = ref.watch( + radianceSettingsProvider.select((s) => s.peerProxy), + ); return ListView( padding: const EdgeInsets.all(0), @@ -117,6 +120,36 @@ class VPNSetting extends HookConsumerWidget { }, ), ), + if (PlatformUtils.isDesktop) ...{ + SizedBox(height: 16), + AppCard( + padding: EdgeInsets.zero, + child: AppTile( + label: 'share_my_connection'.i18n, + subtitle: Text( + 'share_my_connection_subtitle'.i18n, + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + letterSpacing: 0.0, + ), + ), + icon: AppImagePaths.share, + trailing: SwitchButton( + value: peerProxy, + onChanged: (bool? value) { + ref + .read(radianceSettingsProvider.notifier) + .setPeerProxy(value ?? false); + }, + ), + onPressed: () { + ref + .read(radianceSettingsProvider.notifier) + .setPeerProxy(!peerProxy); + }, + ), + ), + }, SizedBox(height: 16), AppCard( padding: EdgeInsets.zero, diff --git a/lib/lantern/lantern_core_service.dart b/lib/lantern/lantern_core_service.dart index 7dde6b538b..124ad9b1d4 100644 --- a/lib/lantern/lantern_core_service.dart +++ b/lib/lantern/lantern_core_service.dart @@ -79,6 +79,10 @@ abstract class LanternCoreService { Future> isBlockAdsEnabled(); + Future> setPeerProxyEnabled(bool enabled); + + Future> isPeerProxyEnabled(); + Future> isSmartRoutingEnabled(); Future> isTelemetryEnabled(); diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index dec4dec5ca..6c206cf5ef 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -1550,6 +1550,34 @@ class LanternFFIService implements LanternCoreService { } } + @override + Future> setPeerProxyEnabled(bool enabled) async { + try { + final result = await runInBackground(() async { + return _ffiService + .setPeerProxyEnabled(enabled ? 1 : 0) + .cast() + .toDartString(); + }); + checkAPIError(result); + return right(unit); + } catch (e, st) { + appLogger.error('setPeerProxyEnabled error: $e', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isPeerProxyEnabled() async { + try { + final res = _ffiService.isPeerProxyEnabled(); + return right(res != 0); + } catch (e, st) { + appLogger.error('isPeerProxyEnabled error: $e', e, st); + return Left(e.toFailure()); + } + } + @override Future> isSmartRoutingEnabled() async { try { diff --git a/lib/lantern/lantern_generated_bindings.dart b/lib/lantern/lantern_generated_bindings.dart index 14cc3944cb..315ad4a52c 100644 --- a/lib/lantern/lantern_generated_bindings.dart +++ b/lib/lantern/lantern_generated_bindings.dart @@ -6275,6 +6275,26 @@ class LanternBindings { late final _isBlockAdsEnabled = _isBlockAdsEnabledPtr .asFunction(); + ffi.Pointer setPeerProxyEnabled(int enabled) { + return _setPeerProxyEnabled(enabled); + } + + late final _setPeerProxyEnabledPtr = + _lookup Function(ffi.Int)>>( + 'setPeerProxyEnabled', + ); + late final _setPeerProxyEnabled = _setPeerProxyEnabledPtr + .asFunction Function(int)>(); + + int isPeerProxyEnabled() { + return _isPeerProxyEnabled(); + } + + late final _isPeerProxyEnabledPtr = + _lookup>('isPeerProxyEnabled'); + late final _isPeerProxyEnabled = _isPeerProxyEnabledPtr + .asFunction(); + ffi.Pointer setSmartRoutingEnabled(int enabled) { return _setSmartRoutingEnabled(enabled); } diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 734c827544..9774c1b7f4 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -290,6 +290,30 @@ class LanternPlatformService implements LanternCoreService { } } + @override + Future> setPeerProxyEnabled(bool enabled) async { + try { + await _methodChannel.invokeMethod('setPeerProxyEnabled', { + 'enabled': enabled, + }); + return right(unit); + } catch (e, st) { + appLogger.error('setPeerProxyEnabled failed', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isPeerProxyEnabled() async { + try { + final res = await _methodChannel.invokeMethod('isPeerProxyEnabled'); + return right(res ?? false); + } catch (e, st) { + appLogger.error('isPeerProxyEnabled failed', e, st); + return Left(e.toFailure()); + } + } + @override Future> isSmartRoutingEnabled() async { try { diff --git a/lib/lantern/lantern_service.dart b/lib/lantern/lantern_service.dart index 2f3ae8fa48..9936cd4164 100644 --- a/lib/lantern/lantern_service.dart +++ b/lib/lantern/lantern_service.dart @@ -794,6 +794,22 @@ class LanternService implements LanternCoreService { return _platformService.setBlockAdsEnabled(enabled); } + @override + Future> isPeerProxyEnabled() { + if (PlatformUtils.isFFISupported) { + return _ffiService.isPeerProxyEnabled(); + } + return _platformService.isPeerProxyEnabled(); + } + + @override + Future> setPeerProxyEnabled(bool enabled) { + if (PlatformUtils.isFFISupported) { + return _ffiService.setPeerProxyEnabled(enabled); + } + return _platformService.setPeerProxyEnabled(enabled); + } + @override Future> isSmartRoutingEnabled() { if (PlatformUtils.isFFISupported) { From 91229385c341a331ae93a3a53a7e10740f64f0c5 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 5 May 2026 06:14:10 -0600 Subject: [PATCH 03/35] review: gate peer-proxy toggle to FFI-supported platforms Three review comments converged on the same root cause: the toggle was gated to PlatformUtils.isDesktop and the platform-service shims invoked MethodChannel methods that have no native handlers anywhere (Android/iOS/macOS), so on any non-FFI platform the toggle would render but the call would fail with MissingPluginException. * vpn_setting.dart: gate to PlatformUtils.isFFISupported (Windows + Linux), where the FFI path actually drives the toggle. * radiance_settings_providers.dart: skip the isPeerProxyEnabled probe in _refresh on non-FFI platforms so we don't log a failure on every settings init. * lantern_platform_service.dart: replace the MethodChannel passthroughs with explicit "not supported on this platform" stubs. They exist only for LanternCoreService interface conformance; the UI gate prevents them from ever being called. macOS / iOS / Android support requires a native handler (Swift / Kotlin) calling into the Go core; that's a follow-up. flutter analyze: clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../provider/radiance_settings_providers.dart | 13 +++++++--- lib/features/setting/vpn_setting.dart | 2 +- lib/lantern/lantern_platform_service.dart | 25 +++++++------------ 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/lib/features/home/provider/radiance_settings_providers.dart b/lib/features/home/provider/radiance_settings_providers.dart index 89ececbb66..b867592c68 100644 --- a/lib/features/home/provider/radiance_settings_providers.dart +++ b/lib/features/home/provider/radiance_settings_providers.dart @@ -29,19 +29,22 @@ class RadianceSettings extends _$RadianceSettings { final routingF = svc.isSmartRoutingEnabled(); final telemetryF = svc.isTelemetryEnabled(); final splitF = PlatformUtils.isIOS ? null : svc.isSplitTunnelingEnabled(); - final peerProxyF = svc.isPeerProxyEnabled(); + // Peer-proxy probe runs only on platforms with native handlers + // (FFI-supported = Windows + Linux). On other platforms the call would + // fail with MissingPluginException on every settings init. + final peerF = PlatformUtils.isFFISupported ? svc.isPeerProxyEnabled() : null; final results = await Future.wait([ blockAdsF, routingF, telemetryF, ?splitF, - peerProxyF, + ?peerF, ]); if (!ref.mounted) return; const defaults = RadianceSettingsState(); - final peerIdx = splitF == null ? 3 : 4; + final peerIdx = 3 + (splitF == null ? 0 : 1); state = RadianceSettingsState( blockAds: results[0].fold((_) => defaults.blockAds, (v) => v), routingMode: results[1].fold( @@ -52,7 +55,9 @@ class RadianceSettings extends _$RadianceSettings { splitTunneling: splitF == null ? defaults.splitTunneling : results[3].fold((_) => defaults.splitTunneling, (v) => v), - peerProxy: results[peerIdx].fold((_) => defaults.peerProxy, (v) => v), + peerProxy: peerF == null + ? defaults.peerProxy + : results[peerIdx].fold((_) => defaults.peerProxy, (v) => v), ); } diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index ec4d3920a5..a50279c94a 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -120,7 +120,7 @@ class VPNSetting extends HookConsumerWidget { }, ), ), - if (PlatformUtils.isDesktop) ...{ + if (PlatformUtils.isFFISupported) ...{ SizedBox(height: 16), AppCard( padding: EdgeInsets.zero, diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 9774c1b7f4..038e6e8b39 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -290,28 +290,21 @@ class LanternPlatformService implements LanternCoreService { } } + // Peer-proxy has no native MethodChannel handler outside the FFI path. + // The toggle is gated to PlatformUtils.isFFISupported in the UI, so these + // implementations exist for interface conformance only and return a clear + // error rather than a MissingPluginException if ever invoked. @override Future> setPeerProxyEnabled(bool enabled) async { - try { - await _methodChannel.invokeMethod('setPeerProxyEnabled', { - 'enabled': enabled, - }); - return right(unit); - } catch (e, st) { - appLogger.error('setPeerProxyEnabled failed', e, st); - return Left(e.toFailure()); - } + return Left(Failure( + error: 'peer-proxy not supported on this platform', + localizedErrorMessage: 'peer-proxy not supported on this platform', + )); } @override Future> isPeerProxyEnabled() async { - try { - final res = await _methodChannel.invokeMethod('isPeerProxyEnabled'); - return right(res ?? false); - } catch (e, st) { - appLogger.error('isPeerProxyEnabled failed', e, st); - return Left(e.toFailure()); - } + return right(false); } @override From c156d636d90a2570126b5c4f152619fcbdfe8af1 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 5 May 2026 10:47:18 -0600 Subject: [PATCH 04/35] peer-proxy: add macOS native handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS routes through MethodChannel → Swift → MobileSetPeerShareEnabled (gomobile-bind) rather than the FFI path that Windows + Linux use. The previous review fix gated the toggle to PlatformUtils.isFFISupported to avoid a MissingPluginException on macOS, but per Phase 1 plan macOS should be supported. * macos/Runner/Handlers/MethodHandler.swift: new setPeerProxyEnabled case + setPeerProxyEnabled function calling MobileSetPeerShareEnabled, plus an isPeerProxyEnabled case calling MobileIsPeerShareEnabled. Mirrors the existing setBlockAdsEnabled handler exactly. (The MobileSet/IsPeerShareEnabled gomobile bindings come from the SetPeerShareEnabled / IsPeerShareEnabled methods added to lantern-core/mobile/mobile.go in PR 8729; the Liblantern xcframework needs a rebuild via `make macos-framework` to pick them up.) * lantern_platform_service.dart: restore the MethodChannel passthrough for setPeerProxyEnabled / isPeerProxyEnabled. The "not supported on this platform" stubs from the prior review fix are no longer appropriate now that there's a native handler. * vpn_setting.dart: widen the toggle gate from isFFISupported (Windows + Linux) to isDesktop (Windows + Linux + macOS). * radiance_settings_providers.dart: same widening for the isPeerProxyEnabled probe in _refresh. Verified locally: `make macos-framework` rebuilds successfully and exports MobileSetPeerShareEnabled / MobileIsPeerShareEnabled. flutter analyze clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../provider/radiance_settings_providers.dart | 7 +++--- lib/features/setting/vpn_setting.dart | 2 +- lib/lantern/lantern_platform_service.dart | 25 ++++++++++++------- macos/Runner/Handlers/MethodHandler.swift | 24 ++++++++++++++++++ 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/lib/features/home/provider/radiance_settings_providers.dart b/lib/features/home/provider/radiance_settings_providers.dart index b867592c68..be8900aede 100644 --- a/lib/features/home/provider/radiance_settings_providers.dart +++ b/lib/features/home/provider/radiance_settings_providers.dart @@ -30,9 +30,10 @@ class RadianceSettings extends _$RadianceSettings { final telemetryF = svc.isTelemetryEnabled(); final splitF = PlatformUtils.isIOS ? null : svc.isSplitTunnelingEnabled(); // Peer-proxy probe runs only on platforms with native handlers - // (FFI-supported = Windows + Linux). On other platforms the call would - // fail with MissingPluginException on every settings init. - final peerF = PlatformUtils.isFFISupported ? svc.isPeerProxyEnabled() : null; + // (Windows + Linux via FFI, macOS via MethodChannel — i.e. all desktop). + // On iOS / Android the call would fail with MissingPluginException on + // every settings init. + final peerF = PlatformUtils.isDesktop ? svc.isPeerProxyEnabled() : null; final results = await Future.wait([ blockAdsF, diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index a50279c94a..ec4d3920a5 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -120,7 +120,7 @@ class VPNSetting extends HookConsumerWidget { }, ), ), - if (PlatformUtils.isFFISupported) ...{ + if (PlatformUtils.isDesktop) ...{ SizedBox(height: 16), AppCard( padding: EdgeInsets.zero, diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 038e6e8b39..9774c1b7f4 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -290,21 +290,28 @@ class LanternPlatformService implements LanternCoreService { } } - // Peer-proxy has no native MethodChannel handler outside the FFI path. - // The toggle is gated to PlatformUtils.isFFISupported in the UI, so these - // implementations exist for interface conformance only and return a clear - // error rather than a MissingPluginException if ever invoked. @override Future> setPeerProxyEnabled(bool enabled) async { - return Left(Failure( - error: 'peer-proxy not supported on this platform', - localizedErrorMessage: 'peer-proxy not supported on this platform', - )); + try { + await _methodChannel.invokeMethod('setPeerProxyEnabled', { + 'enabled': enabled, + }); + return right(unit); + } catch (e, st) { + appLogger.error('setPeerProxyEnabled failed', e, st); + return Left(e.toFailure()); + } } @override Future> isPeerProxyEnabled() async { - return right(false); + try { + final res = await _methodChannel.invokeMethod('isPeerProxyEnabled'); + return right(res ?? false); + } catch (e, st) { + appLogger.error('isPeerProxyEnabled failed', e, st); + return Left(e.toFailure()); + } } @override diff --git a/macos/Runner/Handlers/MethodHandler.swift b/macos/Runner/Handlers/MethodHandler.swift index d41e93e30d..6eaa33d4b2 100644 --- a/macos/Runner/Handlers/MethodHandler.swift +++ b/macos/Runner/Handlers/MethodHandler.swift @@ -245,6 +245,16 @@ class MethodHandler { let enabled = data?["enabled"] as? Bool ?? false self.setBlockAdsEnabled(result: result, enabled: enabled) + case "isPeerProxyEnabled": + Task { + await MainActor.run { result(MobileIsPeerShareEnabled()) } + } + + case "setPeerProxyEnabled": + let data = call.arguments as? [String: Any] + let enabled = data?["enabled"] as? Bool ?? false + self.setPeerProxyEnabled(result: result, enabled: enabled) + case "updateTelemetryEvents": guard let consent: Bool = self.decodeValue(from: call.arguments, result: result) else { return @@ -1152,6 +1162,20 @@ class MethodHandler { } } + func setPeerProxyEnabled(result: @escaping FlutterResult, enabled: Bool) { + Task { + var error: NSError? + MobileSetPeerShareEnabled(enabled, &error) + if let error { + await self.handleFlutterError(error, result: result, code: "SET_PEER_PROXY_ERROR") + return + } + await MainActor.run { + result("ok") + } + } + } + func updateTelemetryEvents(consent: Bool, result: @escaping FlutterResult) { Task { var error: NSError? From 49654557767bf63b7385c5b69e4590a79883b187 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 11:44:42 -0600 Subject: [PATCH 05/35] Prototype: unified Share My Connection screen with globe + SmC disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX prototype combining the Unbounded globe work (from Jigar's #8493 + Adam's #8492) with the Share My Connection FFI plumbing already on this branch. One unified screen, one toggle, one globe — auto-picks SmC when UPnP works and the user accepts the one-time disclosure, otherwise falls back to Unbounded. Backend wiring is mocked for the prototype: - UPnP probe is a 1.5s delay returning a coin-flip (so the demo exercises both the SmC and Unbounded paths across runs) - Connection events come from a 3s timer cycling through canned residential IPs in IR/CN/RU/TR/VN/PK/EG/MM, so the globe arcs animate while the screen is visible Real wiring (radiance peer module event emit, broflake OnConnectionChange plumb-through, persisted SmC acknowledgment, real UPnP probe via FFI) follows once we land the security review CRITICALs (C1/C2/C3). Reuses Jigar's flutter_earth_globe approach verbatim — uv-map textures, GeoLookupService, _GlobeView pattern with addPointConnection arcs. Co-Authored-By: Claude Opus 4.7 (1M context) --- assets/unbounded/uv-map-dark.png | Bin 0 -> 104139 bytes assets/unbounded/uv-map.png | Bin 0 -> 69958 bytes .../models/unbounded_connection_event.dart | 28 + lib/core/services/geo_lookup_service.dart | 173 ++++++ lib/features/setting/vpn_setting.dart | 23 +- .../share_my_connection.dart | 536 ++++++++++++++++++ pubspec.lock | 10 +- pubspec.yaml | 3 + 8 files changed, 761 insertions(+), 12 deletions(-) create mode 100644 assets/unbounded/uv-map-dark.png create mode 100644 assets/unbounded/uv-map.png create mode 100644 lib/core/models/unbounded_connection_event.dart create mode 100644 lib/core/services/geo_lookup_service.dart create mode 100644 lib/features/share_my_connection/share_my_connection.dart diff --git a/assets/unbounded/uv-map-dark.png b/assets/unbounded/uv-map-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..afe9a07568135b06ee2dd2ed13e13f590bef0052 GIT binary patch literal 104139 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 literal 0 HcmV?d00001 diff --git a/assets/unbounded/uv-map.png b/assets/unbounded/uv-map.png new file mode 100644 index 0000000000000000000000000000000000000000..98953b96df72b5536bdc44bdb0c6ecba9d8dc83d GIT binary patch literal 69958 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 ae4XNc-t*N?EnpEq*2iprulUXTU;hCokchJY literal 0 HcmV?d00001 diff --git a/lib/core/models/unbounded_connection_event.dart b/lib/core/models/unbounded_connection_event.dart new file mode 100644 index 0000000000..eb643f255a --- /dev/null +++ b/lib/core/models/unbounded_connection_event.dart @@ -0,0 +1,28 @@ +/// Represents a consumer connection change from the broflake widget proxy. +class UnboundedConnectionEvent { + final int state; // 1 = connected, -1 = disconnected + final int workerIdx; + final String addr; // IP address + + UnboundedConnectionEvent({ + required this.state, + required this.workerIdx, + required this.addr, + }); + + factory UnboundedConnectionEvent.fromJson(Map json) { + return UnboundedConnectionEvent( + state: json['state'] as int, + workerIdx: json['workerIdx'] as int, + addr: json['addr'] as String? ?? '', + ); + } +} + +/// Tracks live and cumulative connection counts for Unbounded. +class UnboundedStats { + final int activeCount; + final int totalCount; + + const UnboundedStats({this.activeCount = 0, this.totalCount = 0}); +} diff --git a/lib/core/services/geo_lookup_service.dart b/lib/core/services/geo_lookup_service.dart new file mode 100644 index 0000000000..50150fe375 --- /dev/null +++ b/lib/core/services/geo_lookup_service.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; + +import 'package:flutter_earth_globe/globe_coordinates.dart'; +import 'package:http/http.dart' as http; + +class GeoLookupService { + static const _geoUrl = 'https://geo.getiantem.org'; + + // ISO country code → approximate centre coordinates + static const _countries = { + 'AF': (lat: 33.0, lng: 65.0), + 'AL': (lat: 41.0, lng: 20.0), + 'DZ': (lat: 28.0, lng: 3.0), + 'AD': (lat: 42.5, lng: 1.6), + 'AO': (lat: -12.5, lng: 18.5), + 'AR': (lat: -34.0, lng: -64.0), + 'AM': (lat: 40.0, lng: 45.0), + 'AU': (lat: -27.0, lng: 133.0), + 'AT': (lat: 47.33, lng: 13.33), + 'AZ': (lat: 40.5, lng: 47.5), + 'BD': (lat: 24.0, lng: 90.0), + 'BY': (lat: 53.0, lng: 28.0), + 'BE': (lat: 50.83, lng: 4.0), + 'BJ': (lat: 9.5, lng: 2.25), + 'BO': (lat: -17.0, lng: -65.0), + 'BA': (lat: 44.0, lng: 18.0), + 'BR': (lat: -10.0, lng: -55.0), + 'BG': (lat: 43.0, lng: 25.0), + 'KH': (lat: 13.0, lng: 105.0), + 'CM': (lat: 6.0, lng: 12.0), + 'CA': (lat: 60.0, lng: -95.0), + 'CL': (lat: -30.0, lng: -71.0), + 'CN': (lat: 35.0, lng: 105.0), + 'CO': (lat: 4.0, lng: -72.0), + 'CD': (lat: 0.0, lng: 25.0), + 'CR': (lat: 10.0, lng: -84.0), + 'HR': (lat: 45.17, lng: 15.5), + 'CU': (lat: 21.5, lng: -80.0), + 'CZ': (lat: 49.75, lng: 15.5), + 'DK': (lat: 56.0, lng: 10.0), + 'DO': (lat: 19.0, lng: -70.67), + 'EC': (lat: -2.0, lng: -77.5), + 'EG': (lat: 27.0, lng: 30.0), + 'SV': (lat: 13.83, lng: -88.92), + 'EE': (lat: 59.0, lng: 26.0), + 'ET': (lat: 8.0, lng: 38.0), + 'FI': (lat: 64.0, lng: 26.0), + 'FR': (lat: 46.0, lng: 2.0), + 'GE': (lat: 42.0, lng: 43.5), + 'DE': (lat: 51.0, lng: 9.0), + 'GH': (lat: 8.0, lng: -2.0), + 'GR': (lat: 39.0, lng: 22.0), + 'GT': (lat: 15.5, lng: -90.25), + 'HN': (lat: 15.0, lng: -86.5), + 'HK': (lat: 22.25, lng: 114.17), + 'HU': (lat: 47.0, lng: 20.0), + 'IS': (lat: 65.0, lng: -18.0), + 'IN': (lat: 20.0, lng: 77.0), + 'ID': (lat: -5.0, lng: 120.0), + 'IR': (lat: 32.0, lng: 53.0), + 'IQ': (lat: 33.0, lng: 44.0), + 'IE': (lat: 53.0, lng: -8.0), + 'IL': (lat: 31.5, lng: 34.75), + 'IT': (lat: 42.83, lng: 12.83), + 'CI': (lat: 8.0, lng: -5.0), + 'JP': (lat: 36.0, lng: 138.0), + 'JO': (lat: 31.0, lng: 36.0), + 'KZ': (lat: 48.0, lng: 68.0), + 'KE': (lat: 1.0, lng: 38.0), + 'KR': (lat: 37.0, lng: 127.5), + 'KW': (lat: 29.34, lng: 47.66), + 'KG': (lat: 41.0, lng: 75.0), + 'LA': (lat: 18.0, lng: 105.0), + 'LV': (lat: 57.0, lng: 25.0), + 'LB': (lat: 33.83, lng: 35.83), + 'LT': (lat: 56.0, lng: 24.0), + 'MG': (lat: -20.0, lng: 47.0), + 'MY': (lat: 2.5, lng: 112.5), + 'ML': (lat: 17.0, lng: -4.0), + 'MX': (lat: 23.0, lng: -102.0), + 'MD': (lat: 47.0, lng: 29.0), + 'MN': (lat: 46.0, lng: 105.0), + 'MA': (lat: 32.0, lng: -5.0), + 'MZ': (lat: -18.25, lng: 35.0), + 'MM': (lat: 22.0, lng: 98.0), + 'NP': (lat: 28.0, lng: 84.0), + 'NL': (lat: 52.5, lng: 5.75), + 'NZ': (lat: -41.0, lng: 174.0), + 'NI': (lat: 13.0, lng: -85.0), + 'NG': (lat: 10.0, lng: 8.0), + 'NO': (lat: 62.0, lng: 10.0), + 'OM': (lat: 21.0, lng: 57.0), + 'PK': (lat: 30.0, lng: 70.0), + 'PA': (lat: 9.0, lng: -80.0), + 'PY': (lat: -23.0, lng: -58.0), + 'PE': (lat: -10.0, lng: -76.0), + 'PH': (lat: 13.0, lng: 122.0), + 'PL': (lat: 52.0, lng: 20.0), + 'PT': (lat: 39.5, lng: -8.0), + 'QA': (lat: 25.5, lng: 51.25), + 'RO': (lat: 46.0, lng: 25.0), + 'RU': (lat: 60.0, lng: 100.0), + 'SA': (lat: 25.0, lng: 45.0), + 'SN': (lat: 14.0, lng: -14.0), + 'RS': (lat: 44.0, lng: 21.0), + 'SG': (lat: 1.37, lng: 103.8), + 'SK': (lat: 48.67, lng: 19.5), + 'SI': (lat: 46.0, lng: 15.0), + 'ZA': (lat: -29.0, lng: 24.0), + 'ES': (lat: 40.0, lng: -4.0), + 'LK': (lat: 7.0, lng: 81.0), + 'SE': (lat: 62.0, lng: 15.0), + 'CH': (lat: 47.0, lng: 8.0), + 'SY': (lat: 35.0, lng: 38.0), + 'TW': (lat: 23.5, lng: 121.0), + 'TJ': (lat: 39.0, lng: 71.0), + 'TZ': (lat: -6.0, lng: 35.0), + 'TH': (lat: 15.0, lng: 100.0), + 'TN': (lat: 34.0, lng: 9.0), + 'TR': (lat: 39.0, lng: 35.0), + 'TM': (lat: 40.0, lng: 60.0), + 'UA': (lat: 49.0, lng: 32.0), + 'AE': (lat: 24.0, lng: 54.0), + 'GB': (lat: 54.0, lng: -2.0), + 'US': (lat: 38.0, lng: -97.0), + 'UY': (lat: -33.0, lng: -56.0), + 'UZ': (lat: 41.0, lng: 64.0), + 'VE': (lat: 8.0, lng: -66.0), + 'VN': (lat: 16.0, lng: 106.0), + 'YE': (lat: 15.0, lng: 48.0), + 'ZM': (lat: -15.0, lng: 30.0), + 'ZW': (lat: -20.0, lng: 30.0), + }; + + static GlobeCoordinates _isoToCoords(String iso) { + final c = _countries[iso] ?? _countries['US']!; + return GlobeCoordinates(c.lat, c.lng); + } + + /// Looks up the current device's location (no IP argument). + static Future selfLookup() async { + try { + final response = await http + .get(Uri.parse('$_geoUrl/')) + .timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + final iso = + (data['Country'] as Map?)?['IsoCode'] as String? ?? + 'US'; + return _isoToCoords(iso); + } + } catch (_) {} + return _isoToCoords('US'); + } + + /// Looks up the country for a peer [ip] address. + static Future peerLookup(String ip) async { + try { + final response = await http + .get(Uri.parse('$_geoUrl/$ip')) + .timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + final iso = + (data['Country'] as Map?)?['IsoCode'] as String? ?? + 'IR'; + return _isoToCoords(iso); + } + } catch (_) {} + return _isoToCoords('IR'); + } +} diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index ec4d3920a5..cfcac3afd7 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -6,6 +6,7 @@ import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/widgets/split_tunneling_tile.dart'; import 'package:lantern/core/widgets/switch_button.dart'; import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; +import 'package:lantern/features/share_my_connection/share_my_connection.dart'; @RoutePage(name: 'VPNSetting') class VPNSetting extends HookConsumerWidget { @@ -127,25 +128,25 @@ class VPNSetting extends HookConsumerWidget { child: AppTile( label: 'share_my_connection'.i18n, subtitle: Text( - 'share_my_connection_subtitle'.i18n, + peerProxy + ? 'On — tap to view' + : 'share_my_connection_subtitle'.i18n, style: textTheme.labelMedium!.copyWith( color: context.textTertiary, letterSpacing: 0.0, ), ), icon: AppImagePaths.share, - trailing: SwitchButton( - value: peerProxy, - onChanged: (bool? value) { - ref - .read(radianceSettingsProvider.notifier) - .setPeerProxy(value ?? false); - }, + trailing: AppImage( + path: AppImagePaths.arrowForward, + height: 20, ), onPressed: () { - ref - .read(radianceSettingsProvider.notifier) - .setPeerProxy(!peerProxy); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ShareMyConnectionScreen(), + ), + ); }, ), ), diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart new file mode 100644 index 0000000000..14c385dcdd --- /dev/null +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -0,0 +1,536 @@ +// Share My Connection — UX prototype. +// One unified screen for both protocols (Unbounded / Share-My-Connection): +// - Toggle ON triggers a (mocked) UPnP probe. +// - If UPnP works AND the user accepts the SmC disclosure, run SmC mode. +// - Otherwise fall back to Unbounded mode. +// - Globe animates connection arcs from a stream of UnboundedConnectionEvent. +// +// All wire-up to radiance / FFI is stubbed for this prototype: the UPnP probe +// returns success after a short delay, and connection events are fired by a +// timer cycling through a small set of canned residential peer IPs so the +// globe actually animates while the screen is visible. + +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_earth_globe/flutter_earth_globe.dart'; +import 'package:flutter_earth_globe/flutter_earth_globe_controller.dart'; +import 'package:flutter_earth_globe/globe_coordinates.dart'; +import 'package:flutter_earth_globe/point.dart'; +import 'package:flutter_earth_globe/point_connection.dart'; +import 'package:flutter_earth_globe/point_connection_style.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/common/common.dart'; +import 'package:lantern/core/models/unbounded_connection_event.dart'; +import 'package:lantern/core/services/geo_lookup_service.dart'; + +// ─── State ─────────────────────────────────────────────────────────────────── + +/// Which underlying protocol the user is contributing through. +/// +/// off — toggle is off / probe in flight +/// unbounded — broflake / WebRTC widget proxy (works on any network) +/// smc — samizdat-over-UPnP "Share My Connection" (higher capability, +/// higher risk; gated on a one-time disclosure) +enum ShareMode { off, unbounded, smc } + +class ShareState { + final bool active; + final bool probing; + final ShareMode mode; + final int activeCount; + final int totalCount; + + const ShareState({ + this.active = false, + this.probing = false, + this.mode = ShareMode.off, + this.activeCount = 0, + this.totalCount = 0, + }); + + ShareState copyWith({ + bool? active, + bool? probing, + ShareMode? mode, + int? activeCount, + int? totalCount, + }) => + ShareState( + active: active ?? this.active, + probing: probing ?? this.probing, + mode: mode ?? this.mode, + activeCount: activeCount ?? this.activeCount, + totalCount: totalCount ?? this.totalCount, + ); +} + +// ─── Notifier (mock-backed) ────────────────────────────────────────────────── + +class ShareNotifier extends Notifier { + // Persisted in real impl; in-process for the prototype so the disclosure + // re-fires on app restart and is easy to demo. + bool _smcAck = false; + + Timer? _mockTimer; + int _workerSeq = 0; + final List _activeWorkers = []; + + final _eventController = + StreamController.broadcast(); + Stream get connectionEvents => + _eventController.stream; + + @override + ShareState build() { + ref.onDispose(() { + _stopMockEvents(); + _eventController.close(); + }); + return const ShareState(); + } + + /// Toggle entry point. Caller passes its BuildContext so we can show the + /// disclosure modal inline. + Future toggle(BuildContext context) async { + if (state.active || state.probing) { + _stop(); + return; + } + + state = state.copyWith(probing: true); + + // MOCK: real impl will FFI into radiance/portforward to probe UPnP. + // Coin-flip the result so the demo exercises both the SmC and Unbounded + // paths across runs; flip to `true` for the SmC path while iterating on + // the disclosure copy. + await Future.delayed(const Duration(milliseconds: 1500)); + final upnpAvailable = Random().nextBool(); + if (!upnpAvailable) { + _start(ShareMode.unbounded); + return; + } + + if (_smcAck) { + _start(ShareMode.smc); + return; + } + + if (!context.mounted) { + state = state.copyWith(probing: false); + return; + } + + final accepted = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const SmcDisclosureDialog(), + ); + + if (accepted == null) { + // User dismissed without choosing — leave off. + state = state.copyWith(probing: false); + return; + } + if (accepted) { + _smcAck = true; + _start(ShareMode.smc); + } else { + _start(ShareMode.unbounded); + } + } + + void _start(ShareMode mode) { + state = ShareState( + active: true, + probing: false, + mode: mode, + activeCount: 0, + totalCount: 0, + ); + _startMockEvents(); + } + + void _stop() { + _stopMockEvents(); + state = const ShareState(); + } + + // ── Mock connection event source ─────────────────────────────────────────── + // A small canned set of (country, IP) pairs heavy on Lantern's priority + // censored regions, so the globe shows arcs landing where users actually + // benefit from the network. Real impl will subscribe to a radiance event. + + static const _peerIPs = [ + '5.190.10.5', // IR + '120.196.10.5', // CN + '95.165.10.5', // RU + '85.159.10.5', // TR + '113.161.10.5', // VN + '111.68.10.5', // PK + '156.197.10.5', // EG + '103.81.10.5', // MM + '37.156.10.5', // IR (second) + '202.108.10.5', // CN (second) + ]; + + void _startMockEvents() { + final rand = Random(); + _mockTimer = Timer.periodic(const Duration(seconds: 3), (_) { + // Bias toward connecting until we have ~4 active, then 50/50 churn. + final shouldConnect = + _activeWorkers.length < 4 || rand.nextDouble() < 0.5; + + if (shouldConnect) { + final addr = _peerIPs[rand.nextInt(_peerIPs.length)]; + final widx = _workerSeq++; + _activeWorkers.add(widx); + _eventController.add(UnboundedConnectionEvent( + state: 1, + workerIdx: widx, + addr: addr, + )); + state = state.copyWith( + activeCount: state.activeCount + 1, + totalCount: state.totalCount + 1, + ); + } else if (_activeWorkers.isNotEmpty) { + final widx = _activeWorkers.removeAt(0); + _eventController.add(UnboundedConnectionEvent( + state: -1, + workerIdx: widx, + addr: '', + )); + state = state.copyWith( + activeCount: max(0, state.activeCount - 1), + ); + } + }); + } + + void _stopMockEvents() { + _mockTimer?.cancel(); + _mockTimer = null; + _activeWorkers.clear(); + _workerSeq = 0; + } +} + +final shareProvider = + NotifierProvider(ShareNotifier.new); + +// ─── Screen ────────────────────────────────────────────────────────────────── + +class ShareMyConnectionScreen extends HookConsumerWidget { + const ShareMyConnectionScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(shareProvider); + final notifier = ref.read(shareProvider.notifier); + final textTheme = Theme.of(context).textTheme; + + return BaseScreen( + title: 'Share My Connection', + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + const SizedBox(height: 12), + Text( + 'Help others bypass censorship by sharing a small portion of ' + 'your home internet connection. While sharing is on, traffic ' + 'from users in censored regions will egress through your IP.', + style: textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Expanded( + flex: 3, + child: _GlobeView(), + ), + const SizedBox(height: 8), + _StatusCard(state: state, onToggle: () => notifier.toggle(context)), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} + +// ─── Status card ───────────────────────────────────────────────────────────── + +class _StatusCard extends StatelessWidget { + final ShareState state; + final VoidCallback onToggle; + + const _StatusCard({required this.state, required this.onToggle}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final modeLabel = switch (state.mode) { + ShareMode.off => state.probing ? 'Probing your network…' : 'Off', + ShareMode.unbounded => + 'Active — sharing via Unbounded (WebRTC)', + ShareMode.smc => + 'Active — sharing via Share My Connection (residential proxy)', + }; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.black12), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Status', + style: textTheme.labelLarge, + ), + const SizedBox(height: 4), + Text( + modeLabel, + style: textTheme.bodyMedium?.copyWith( + color: state.active + ? AppColors.blue4 + : Theme.of(context).hintColor, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + Switch( + value: state.active || state.probing, + onChanged: state.probing ? null : (_) => onToggle(), + ), + ], + ), + if (state.active) ...[ + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _Stat(label: 'Active now', value: '${state.activeCount}'), + _Stat(label: 'Total today', value: '${state.totalCount}'), + ], + ), + ], + ], + ), + ); + } +} + +class _Stat extends StatelessWidget { + final String label; + final String value; + const _Stat({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Column( + children: [ + Text(value, style: textTheme.headlineSmall), + Text(label, style: textTheme.labelSmall), + ], + ); + } +} + +// ─── Globe ─────────────────────────────────────────────────────────────────── + +class _GlobeView extends ConsumerStatefulWidget { + @override + ConsumerState<_GlobeView> createState() => _GlobeViewState(); +} + +class _GlobeViewState extends ConsumerState<_GlobeView> { + static final _arcColor = AppColors.blue4.withValues(alpha: 0.75); + static final _originPointColor = AppColors.blue4.withValues(alpha: 0.15); + static final _peerPointColor = AppColors.yellow3.withValues(alpha: 0.15); + static const _atmosphereDark = AppColors.blue4; + static const _atmosphereLight = AppColors.blue6; + + final FlutterEarthGlobeController _globeController = + FlutterEarthGlobeController( + isRotating: true, + rotationSpeed: 0.04, + zoom: 0, + isZoomEnabled: false, + showAtmosphere: true, + atmosphereColor: _atmosphereDark, + atmosphereOpacity: 0.2, + atmosphereBlur: 20, + ); + + StreamSubscription? _eventSub; + GlobeCoordinates? _originCoords; + + @override + void initState() { + super.initState(); + _globeController.onLoaded = () { + if (!mounted) return; + _applyTheme(); + }; + _initOrigin(); + _eventSub = ref + .read(shareProvider.notifier) + .connectionEvents + .listen(_handleEvent); + } + + @override + void dispose() { + _eventSub?.cancel(); + _globeController.dispose(); + super.dispose(); + } + + void _applyTheme() { + final isDark = Theme.of(context).brightness == Brightness.dark; + _globeController.loadSurface(AssetImage( + isDark + ? 'assets/unbounded/uv-map-dark.png' + : 'assets/unbounded/uv-map.png', + )); + _globeController.atmosphereColor = + isDark ? _atmosphereDark : _atmosphereLight; + } + + Future _initOrigin() async { + final coords = await GeoLookupService.selfLookup(); + if (!mounted) return; + _originCoords = coords; + _globeController.addPoint(Point( + id: 'origin', + coordinates: coords, + style: PointStyle(color: _originPointColor, size: 8), + )); + } + + Future _handleEvent(UnboundedConnectionEvent event) async { + if (event.state == 1 && event.addr.isNotEmpty) { + await _addPeer(event.workerIdx, event.addr); + } else if (event.state == -1) { + _removePeer(event.workerIdx); + } + } + + Future _addPeer(int workerIdx, String addr) async { + final coords = await GeoLookupService.peerLookup(addr); + if (!mounted) return; + _globeController.addPointConnection(PointConnection( + id: 'conn_$workerIdx', + start: _originCoords ?? const GlobeCoordinates(0, 0), + end: coords, + curveScale: .6, + style: PointConnectionStyle( + color: _arcColor, + lineWidth: 3, + type: PointConnectionType.solid, + dashAnimateTime: 1000, + dashSize: 13, + spacing: 15, + dotSize: 10, + animateOnAdd: true, + ), + )); + _globeController.addPoint(Point( + id: 'peer_$workerIdx', + coordinates: coords, + style: PointStyle(color: _peerPointColor, size: 6), + )); + } + + void _removePeer(int workerIdx) { + _globeController.removePointConnection('conn_$workerIdx'); + _globeController.removePoint('peer_$workerIdx'); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final radius = + min(constraints.maxWidth, constraints.maxHeight) / 2 * 0.7; + return FlutterEarthGlobe( + controller: _globeController, + radius: radius, + alignment: Alignment.center, + onZoomChanged: (_) {}, + ); + }, + ); + } +} + +// ─── Disclosure dialog ─────────────────────────────────────────────────────── + +class SmcDisclosureDialog extends StatelessWidget { + const SmcDisclosureDialog({super.key}); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return AlertDialog( + title: const Text('Use full Share My Connection?'), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Your network supports the higher-bandwidth, more ' + 'block-resistant mode. In this mode, your home internet ' + 'connection routes traffic for users in censored countries.', + style: textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'Lantern blocks abuse destinations, rotates credentials, ' + 'and operates as a "mere conduit" under DMCA § 512(a) — ' + 'but your IP address will appear in the destination\'s ' + 'logs while you\'re sharing.', + style: textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'You can still help by selecting "Basic mode" instead, ' + 'which uses ephemeral WebRTC connections that are not ' + 'tied to your IP in the same way.', + style: textTheme.bodyMedium?.copyWith( + color: Theme.of(context).hintColor, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Basic mode (Unbounded)'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Full mode (SmC)'), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index aa05b04761..204a170f99 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -547,6 +547,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_earth_globe: + dependency: "direct main" + description: + name: flutter_earth_globe + sha256: a5461c43f4dbf1c1af4e8fa46293c83e97b8af7908969ccd6c324282e60d9a31 + url: "https://pub.dev" + source: hosted + version: "2.2.1" flutter_hooks: dependency: "direct main" description: @@ -832,7 +840,7 @@ packages: source: hosted version: "4.3.0" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/pubspec.yaml b/pubspec.yaml index b29849c89c..919146cfe8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,8 @@ dependencies: #Routing auto_route: ^11.1.0 #UI Utils + flutter_earth_globe: ^2.2.0 + http: ^1.2.2 animated_toggle_switch: ^0.8.7 animated_text_kit: ^4.3.0 flutter_screenutil: ^5.9.3 @@ -163,6 +165,7 @@ flutter: platforms: - windows - assets/locales/ + - assets/unbounded/ - app.env ffigen: From cf474aaf2861f83a906ea63b3dea544d45c105ca Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 12:13:19 -0600 Subject: [PATCH 06/35] =?UTF-8?q?prototype:=20globe=20wasn't=20visible=20?= =?UTF-8?q?=E2=80=94=20restore=20MediaQuery=20override=20+=20ClipRect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flutter_earth_globe positions the sphere relative to MediaQuery.size (full screen) by default, so embedding it in a non-fullscreen layout slot puts the sphere off-screen. The original unbounded.dart wrapped it in MediaQuery + Positioned.fill + ClipRect to keep the sphere centred inside the parent widget's bounds — I'd dropped those when porting. Restored. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../share_my_connection.dart | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 14c385dcdd..a68e157347 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -467,13 +467,30 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { + // FlutterEarthGlobe positions the sphere relative to MediaQuery.size + // (i.e. the full screen). Without overriding it the globe ends up + // off-screen when it lives in a non-fullscreen layout slot. The + // MediaQuery override + Positioned.fill keeps the sphere centred + // within this widget's box; ClipRect keeps arcs from painting + // outside the box when they curve high. + final widgetSize = Size(constraints.maxWidth, constraints.maxHeight); final radius = min(constraints.maxWidth, constraints.maxHeight) / 2 * 0.7; - return FlutterEarthGlobe( - controller: _globeController, - radius: radius, - alignment: Alignment.center, - onZoomChanged: (_) {}, + return ClipRect( + child: MediaQuery( + data: MediaQueryData(size: widgetSize), + child: Stack( + children: [ + Positioned.fill( + child: FlutterEarthGlobe( + controller: _globeController, + radius: radius, + alignment: const Alignment(0.0, 0.1), + ), + ), + ], + ), + ), ); }, ); From 8eb80cb825c4df191c51adb09425efa7f85a1d51 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 12:23:46 -0600 Subject: [PATCH 07/35] prototype: use SwitchButton to match the rest of the app's toggles Co-Authored-By: Claude Opus 4.7 (1M context) --- .../share_my_connection/share_my_connection.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index a68e157347..ef69a6624e 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -24,6 +24,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/unbounded_connection_event.dart'; import 'package:lantern/core/services/geo_lookup_service.dart'; +import 'package:lantern/core/widgets/switch_button.dart'; // ─── State ─────────────────────────────────────────────────────────────────── @@ -311,9 +312,16 @@ class _StatusCard extends StatelessWidget { ], ), ), - Switch( + // Match the rest of the app's toggles (vpn_setting.dart etc.). + // SwitchButton has no built-in disabled state, so during the + // probe we render the switch but absorb the tap so the user + // doesn't double-fire toggle(). + SwitchButton( value: state.active || state.probing, - onChanged: state.probing ? null : (_) => onToggle(), + onChanged: (value) { + if (state.probing) return; + onToggle(); + }, ), ], ), From 02e43f4c6e568eac241eb0f4b1e01b2db0297360 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 12:24:01 -0600 Subject: [PATCH 08/35] =?UTF-8?q?prototype:=20nudge=20the=20globe=20up=20?= =?UTF-8?q?=E2=80=94=20alignment(0,=200.1)=20=E2=86=92=20(0,=20-0.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/features/share_my_connection/share_my_connection.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index ef69a6624e..8728988516 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -493,7 +493,7 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { child: FlutterEarthGlobe( controller: _globeController, radius: radius, - alignment: const Alignment(0.0, 0.1), + alignment: const Alignment(0.0, -0.1), ), ), ], From b93e873740d2b23702e9c9414749bdbea91f7bba Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 12:42:05 -0600 Subject: [PATCH 09/35] prototype: replace mock event timer with poll of radiance peer stats endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dart side now reads live connection state from the radiance peer client's localhost stats endpoint (127.0.0.1:17099/peer/connections) every 3s and diffs against the last snapshot to fire +1 / -1 events for the globe arcs. Globe origin is unchanged; arc destinations are real connected client IPs from Iran / China / Russia / etc. as the bandit assigns them. If the endpoint isn't up yet (peer.Client.Start in flight, or no real radiance peer process attached), the poll silently retries; the globe stays empty until the first successful snapshot. The IP→country geo lookup still runs through GeoLookupService.peerLookup (geo.getiantem.org), so each arc lands on the connecting client's country centroid. Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 6 +- .../share_my_connection.dart | 113 +++++++++++------- 2 files changed, 74 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index e4790244f2..d54c960fda 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,11 @@ module github.com/getlantern/lantern go 1.26.2 -// replace github.com/getlantern/radiance => ../radiance +// Local while peer connection-stats endpoint is in flight; remove once +// radiance tags a release that includes peer/connstats.go. +replace github.com/getlantern/radiance => ../radiance + +replace github.com/getlantern/lantern-box => ../lantern-box // replace github.com/getlantern/lantern-server-provisioner => ../lantern-server-provisioner diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 8728988516..04513f43c0 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -11,9 +11,11 @@ // globe actually animates while the screen is visible. import 'dart:async'; +import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; import 'package:flutter_earth_globe/flutter_earth_globe.dart'; import 'package:flutter_earth_globe/flutter_earth_globe_controller.dart'; import 'package:flutter_earth_globe/globe_coordinates.dart'; @@ -158,54 +160,75 @@ class ShareNotifier extends Notifier { state = const ShareState(); } - // ── Mock connection event source ─────────────────────────────────────────── - // A small canned set of (country, IP) pairs heavy on Lantern's priority - // censored regions, so the globe shows arcs landing where users actually - // benefit from the network. Real impl will subscribe to a radiance event. - - static const _peerIPs = [ - '5.190.10.5', // IR - '120.196.10.5', // CN - '95.165.10.5', // RU - '85.159.10.5', // TR - '113.161.10.5', // VN - '111.68.10.5', // PK - '156.197.10.5', // EG - '103.81.10.5', // MM - '37.156.10.5', // IR (second) - '202.108.10.5', // CN (second) - ]; + // ── Live connection event source ─────────────────────────────────────────── + // Polls the radiance peer client's localhost stats endpoint (see + // radiance/peer/connstats.go) every 3s, diffs against the last + // snapshot, and emits +1/-1 events to drive the globe arcs. + // + // The radiance side strips the port from "ip:port" before serving, so + // each entry in `sources` is a bare IP. Worker index is assigned + // monotonically per source; we keep a source→workerIdx map so the + // disconnect side fires the matching event for the same arc. + // + // Stats endpoint defaults to 127.0.0.1:17099; override with + // RADIANCE_PEER_STATS_ADDR in the radiance process if it conflicts. + + static const _statsURL = 'http://127.0.0.1:17099/peer/connections'; + + final Map _sourceToWorker = {}; + Set _lastSeen = const {}; void _startMockEvents() { - final rand = Random(); - _mockTimer = Timer.periodic(const Duration(seconds: 3), (_) { - // Bias toward connecting until we have ~4 active, then 50/50 churn. - final shouldConnect = - _activeWorkers.length < 4 || rand.nextDouble() < 0.5; - - if (shouldConnect) { - final addr = _peerIPs[rand.nextInt(_peerIPs.length)]; - final widx = _workerSeq++; - _activeWorkers.add(widx); - _eventController.add(UnboundedConnectionEvent( - state: 1, - workerIdx: widx, - addr: addr, - )); - state = state.copyWith( - activeCount: state.activeCount + 1, - totalCount: state.totalCount + 1, - ); - } else if (_activeWorkers.isNotEmpty) { - final widx = _activeWorkers.removeAt(0); - _eventController.add(UnboundedConnectionEvent( - state: -1, - workerIdx: widx, - addr: '', - )); + _lastSeen = const {}; + _sourceToWorker.clear(); + _mockTimer = Timer.periodic(const Duration(seconds: 3), (_) async { + try { + final resp = await http + .get(Uri.parse(_statsURL)) + .timeout(const Duration(seconds: 2)); + if (resp.statusCode != 200) return; + final body = jsonDecode(resp.body) as Map; + final sourcesRaw = (body['sources'] as List?) ?? const []; + // Strip ":port" — the globe only cares about the IP. + final sources = { + for (final s in sourcesRaw) + (s as String).split(':').first, + }..removeWhere((s) => s.isEmpty); + + // Disconnects: in last but not in current. + for (final gone in _lastSeen.difference(sources)) { + final widx = _sourceToWorker.remove(gone); + if (widx != null) { + _eventController.add(UnboundedConnectionEvent( + state: -1, + workerIdx: widx, + addr: '', + )); + } + } + // Connects: in current but not in last. + for (final fresh in sources.difference(_lastSeen)) { + final widx = _workerSeq++; + _sourceToWorker[fresh] = widx; + _eventController.add(UnboundedConnectionEvent( + state: 1, + workerIdx: widx, + addr: fresh, + )); + } + _lastSeen = sources; + + // Refresh totals from the snapshot (active_count is authoritative; + // totalCount accumulates across the session for the status card). + final activeCount = + (body['active_count'] as num?)?.toInt() ?? sources.length; state = state.copyWith( - activeCount: max(0, state.activeCount - 1), + activeCount: activeCount, + totalCount: max(state.totalCount, _workerSeq), ); + } catch (_) { + // Endpoint may not be up yet (peer.Client.Start in flight) or the + // user never had a real radiance peer attached. Silently retry. } }); } @@ -214,6 +237,8 @@ class ShareNotifier extends Notifier { _mockTimer?.cancel(); _mockTimer = null; _activeWorkers.clear(); + _sourceToWorker.clear(); + _lastSeen = const {}; _workerSeq = 0; } } From 3bb278836ab377743fa6cf67ed6e3f5799976bb8 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 13:19:00 -0600 Subject: [PATCH 10/35] Stream peer-connection events from radiance to Flutter via the existing FlutterEvent bridge; wire SmC toggle to the real radiance peer module. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The localhost stats HTTP endpoint approach was reverted in radiance (detectability + extra attack surface). This swaps it for the existing Dart api_dl FlutterEvent channel — same bridge already carrying config / server-location / data-cap events, no new ports, no new process boundaries. lantern-core/core.go: - New EventTypePeerConnection event type, message JSON {state: +1|-1, source: "ip:port"}. - listenPeerConnectionEvents goroutine subscribes to radiance events.Subscribe[peer.ConnectionEvent] and forwards via notifyFlutter, which lights up the same appEventPort that AppEventNotifier already listens on. lib/features/share_my_connection/share_my_connection.dart: - Replaced the HTTP poll loop with a subscription to lanternServiceProvider.watchAppEvents(), filtered for type=='peer-connection'. Same UnboundedConnectionEvent shape goes into the existing globe stream — globe widget unchanged. - Wired the toggle to actually flip the real radiance peer module on for SmC mode via radianceSettingsProvider.setPeerProxy(true); the OFF path calls setPeerProxy(false) when the active mode was SmC (no-op otherwise so Unbounded mode doesn't accidentally tear down a peer that was never started). - Unbounded mode remains UI-only on this branch; broflake plumbing follows when radiance#336 lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- lantern-core/core.go | 28 +++ .../share_my_connection.dart | 188 ++++++++++-------- 2 files changed, 128 insertions(+), 88 deletions(-) diff --git a/lantern-core/core.go b/lantern-core/core.go index 4c4669e9f5..83f6cc5d83 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -18,8 +18,10 @@ import ( "github.com/getlantern/radiance/common" "github.com/getlantern/radiance/common/env" "github.com/getlantern/radiance/common/settings" + "github.com/getlantern/radiance/events" "github.com/getlantern/radiance/ipc" "github.com/getlantern/radiance/issue" + "github.com/getlantern/radiance/peer" "github.com/getlantern/radiance/servers" "github.com/getlantern/radiance/vpn" @@ -36,6 +38,11 @@ const ( EventTypeServerLocation EventType = "server-location" EventTypeConfig EventType = "config" EventTypeCountryCode EventType = "country-code" + // EventTypePeerConnection signals a samizdat peer accept/close on the + // local Share My Connection inbound. Message is JSON + // {"state": +1|-1, "source": "ip:port"}; consumers extract the IP for + // geo-lookup or rate-limit attribution. + EventTypePeerConnection EventType = "peer-connection" DefaultLogLevel = "trace" ) @@ -246,6 +253,7 @@ func (lc *LanternCore) initialize(opts *utils.Opts, eventEmitter utils.FlutterEv go lc.listenAutoSelectedEvents() go lc.listenConfigEvents() go lc.listenDataCapEvents() + go lc.listenPeerConnectionEvents() go lc.fetchUserDataIfNeeded() slog.Debug("LanternCore initialized successfully") @@ -360,6 +368,26 @@ func (lc *LanternCore) listenDataCapEvents() { } } +// listenPeerConnectionEvents forwards samizdat accept/close events from the +// radiance peer client to the Flutter side via the existing FlutterEvent +// emitter, so the Share My Connection globe can render arcs as remote +// clients connect to the local peer's inbound. Subscription is process- +// lifetime; events.Subscribe is decoupled from peer.Client lifecycle and +// silently delivers nothing while no peer is active. +func (lc *LanternCore) listenPeerConnectionEvents() { + events.Subscribe(func(evt peer.ConnectionEvent) { + jsonBytes, err := json.Marshal(map[string]any{ + "state": evt.State, + "source": evt.Source, + }) + if err != nil { + slog.Error("marshal peer connection event", "error", err) + return + } + lc.notifyFlutter(EventTypePeerConnection, string(jsonBytes)) + }) +} + ///////////////// // VPN // ///////////////// diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 04513f43c0..f84116c675 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -1,21 +1,23 @@ -// Share My Connection — UX prototype. -// One unified screen for both protocols (Unbounded / Share-My-Connection): +// Share My Connection — unified screen for both Unbounded and the +// samizdat-over-UPnP "Share My Connection" modes: // - Toggle ON triggers a (mocked) UPnP probe. -// - If UPnP works AND the user accepts the SmC disclosure, run SmC mode. -// - Otherwise fall back to Unbounded mode. -// - Globe animates connection arcs from a stream of UnboundedConnectionEvent. +// - If UPnP works AND the user accepts the SmC disclosure, run SmC mode +// (calls into radiance via the existing radianceSettingsProvider +// setPeerProxy path). +// - Otherwise fall back to Unbounded mode (UI-only for now; broflake +// wire-up follows once radiance#336 lands). +// - Globe animates connection arcs from peer-connection FlutterEvents +// streamed up from radiance. // -// All wire-up to radiance / FFI is stubbed for this prototype: the UPnP probe -// returns success after a short delay, and connection events are fired by a -// timer cycling through a small set of canned residential peer IPs so the -// globe actually animates while the screen is visible. +// UPnP probe is still mocked (a coin-flip) until the FFI binding lands; +// SmC mode is real — flipping the toggle starts the radiance peer +// module on this branch. import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; import 'package:flutter_earth_globe/flutter_earth_globe.dart'; import 'package:flutter_earth_globe/flutter_earth_globe_controller.dart'; import 'package:flutter_earth_globe/globe_coordinates.dart'; @@ -27,6 +29,8 @@ import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/unbounded_connection_event.dart'; import 'package:lantern/core/services/geo_lookup_service.dart'; import 'package:lantern/core/widgets/switch_button.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; +import 'package:lantern/lantern/lantern_service_notifier.dart'; // ─── State ─────────────────────────────────────────────────────────────────── @@ -76,9 +80,9 @@ class ShareNotifier extends Notifier { // re-fires on app restart and is easy to demo. bool _smcAck = false; - Timer? _mockTimer; + StreamSubscription? _appEventSub; int _workerSeq = 0; - final List _activeWorkers = []; + final Map _sourceToWorker = {}; final _eventController = StreamController.broadcast(); @@ -88,17 +92,18 @@ class ShareNotifier extends Notifier { @override ShareState build() { ref.onDispose(() { - _stopMockEvents(); + _stopEventSubscription(); _eventController.close(); }); return const ShareState(); } /// Toggle entry point. Caller passes its BuildContext so we can show the - /// disclosure modal inline. - Future toggle(BuildContext context) async { + /// disclosure modal inline, and a WidgetRef so we can drive the radiance + /// peer-share toggle. + Future toggle(BuildContext context, WidgetRef widgetRef) async { if (state.active || state.probing) { - _stop(); + await _stop(widgetRef); return; } @@ -111,12 +116,12 @@ class ShareNotifier extends Notifier { await Future.delayed(const Duration(milliseconds: 1500)); final upnpAvailable = Random().nextBool(); if (!upnpAvailable) { - _start(ShareMode.unbounded); + await _start(widgetRef, ShareMode.unbounded); return; } if (_smcAck) { - _start(ShareMode.smc); + await _start(widgetRef, ShareMode.smc); return; } @@ -138,13 +143,13 @@ class ShareNotifier extends Notifier { } if (accepted) { _smcAck = true; - _start(ShareMode.smc); + await _start(widgetRef, ShareMode.smc); } else { - _start(ShareMode.unbounded); + await _start(widgetRef, ShareMode.unbounded); } } - void _start(ShareMode mode) { + Future _start(WidgetRef widgetRef, ShareMode mode) async { state = ShareState( active: true, probing: false, @@ -152,93 +157,100 @@ class ShareNotifier extends Notifier { activeCount: 0, totalCount: 0, ); - _startMockEvents(); + _startEventSubscription(widgetRef); + if (mode == ShareMode.smc) { + // Flip the radiance peer-proxy setting; LocalBackend.PatchSettings + // routes that into peer.Client.Start, which spins up the UPnP map, + // registers with lantern-cloud, runs the samizdat inbound, and (via + // the lantern-box peerconn listener radiance/peer/peer.go now sets) + // emits ConnectionEvents that ride the radiance event bus → core.go + // listenPeerConnectionEvents → FlutterEvent → our Dart subscription. + await widgetRef + .read(radianceSettingsProvider.notifier) + .setPeerProxy(true); + } + // Unbounded mode is UI-only on this branch; broflake plumbing follows + // when radiance#336 lands. } - void _stop() { - _stopMockEvents(); + Future _stop(WidgetRef widgetRef) async { + _stopEventSubscription(); + final wasSmc = state.mode == ShareMode.smc; state = const ShareState(); + if (wasSmc) { + await widgetRef + .read(radianceSettingsProvider.notifier) + .setPeerProxy(false); + } } // ── Live connection event source ─────────────────────────────────────────── - // Polls the radiance peer client's localhost stats endpoint (see - // radiance/peer/connstats.go) every 3s, diffs against the last - // snapshot, and emits +1/-1 events to drive the globe arcs. + // Subscribes to the existing FFI app-event stream (the same one + // AppEventNotifier uses for config / server-location / data-cap events) + // and filters for type=='peer-connection'. Each event's message is + // {state: +1|-1, source: "ip:port"} originally emitted from the + // lantern-box samizdat inbound via the peerconn listener registry, then + // rebroadcast by lantern-core/core.go listenPeerConnectionEvents. // - // The radiance side strips the port from "ip:port" before serving, so - // each entry in `sources` is a bare IP. Worker index is assigned - // monotonically per source; we keep a source→workerIdx map so the - // disconnect side fires the matching event for the same arc. - // - // Stats endpoint defaults to 127.0.0.1:17099; override with - // RADIANCE_PEER_STATS_ADDR in the radiance process if it conflicts. - - static const _statsURL = 'http://127.0.0.1:17099/peer/connections'; - - final Map _sourceToWorker = {}; - Set _lastSeen = const {}; + // No local sockets, no fixed ports — the bridge rides on Dart api_dl, + // which is the same channel server-location updates and data-cap events + // already use. - void _startMockEvents() { - _lastSeen = const {}; + void _startEventSubscription(WidgetRef widgetRef) { _sourceToWorker.clear(); - _mockTimer = Timer.periodic(const Duration(seconds: 3), (_) async { + _appEventSub = widgetRef + .read(lanternServiceProvider) + .watchAppEvents() + .listen((event) { + if (event.eventType != 'peer-connection') return; try { - final resp = await http - .get(Uri.parse(_statsURL)) - .timeout(const Duration(seconds: 2)); - if (resp.statusCode != 200) return; - final body = jsonDecode(resp.body) as Map; - final sourcesRaw = (body['sources'] as List?) ?? const []; - // Strip ":port" — the globe only cares about the IP. - final sources = { - for (final s in sourcesRaw) - (s as String).split(':').first, - }..removeWhere((s) => s.isEmpty); - - // Disconnects: in last but not in current. - for (final gone in _lastSeen.difference(sources)) { - final widx = _sourceToWorker.remove(gone); - if (widx != null) { - _eventController.add(UnboundedConnectionEvent( - state: -1, - workerIdx: widx, - addr: '', - )); - } - } - // Connects: in current but not in last. - for (final fresh in sources.difference(_lastSeen)) { + final payload = jsonDecode(event.message) as Map; + final eventState = (payload['state'] as num?)?.toInt() ?? 0; + final source = (payload['source'] as String?) ?? ''; + // Globe only cares about the IP — strip ":port". + final ip = source.split(':').first; + if (ip.isEmpty) return; + + if (eventState == 1) { + // Each (source IP) gets a stable worker idx so the matching + // disconnect can find the arc to remove. Repeated +1 from the + // same source (re-connect after disconnect) gets a new idx. + if (_sourceToWorker.containsKey(ip)) return; final widx = _workerSeq++; - _sourceToWorker[fresh] = widx; + _sourceToWorker[ip] = widx; _eventController.add(UnboundedConnectionEvent( state: 1, workerIdx: widx, - addr: fresh, + addr: ip, + )); + state = state.copyWith( + activeCount: state.activeCount + 1, + totalCount: state.totalCount + 1, + ); + } else if (eventState == -1) { + final widx = _sourceToWorker.remove(ip); + if (widx == null) return; + _eventController.add(UnboundedConnectionEvent( + state: -1, + workerIdx: widx, + addr: '', )); + state = state.copyWith( + activeCount: max(0, state.activeCount - 1), + ); } - _lastSeen = sources; - - // Refresh totals from the snapshot (active_count is authoritative; - // totalCount accumulates across the session for the status card). - final activeCount = - (body['active_count'] as num?)?.toInt() ?? sources.length; - state = state.copyWith( - activeCount: activeCount, - totalCount: max(state.totalCount, _workerSeq), - ); - } catch (_) { - // Endpoint may not be up yet (peer.Client.Start in flight) or the - // user never had a real radiance peer attached. Silently retry. + } catch (e) { + // Malformed event — log via dev print to avoid bringing in the + // appLogger here. Real impl can switch to slog. + debugPrint('share-my-connection: bad peer-connection event: $e'); } }); } - void _stopMockEvents() { - _mockTimer?.cancel(); - _mockTimer = null; - _activeWorkers.clear(); + void _stopEventSubscription() { + _appEventSub?.cancel(); + _appEventSub = null; _sourceToWorker.clear(); - _lastSeen = const {}; _workerSeq = 0; } } @@ -276,7 +288,7 @@ class ShareMyConnectionScreen extends HookConsumerWidget { child: _GlobeView(), ), const SizedBox(height: 8), - _StatusCard(state: state, onToggle: () => notifier.toggle(context)), + _StatusCard(state: state, onToggle: () => notifier.toggle(context, ref)), const SizedBox(height: 16), ], ), From db0249fbcbf5dd09591cf6f97c9826d58ea27b92 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 14:29:07 -0600 Subject: [PATCH 11/35] Advanced section in Share My Connection: manual port forward setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For users on networks where UPnP doesn't work (most consumer routers ship with UPnP off by default, ISP gateways without IGD, double-NAT networks), this adds a UI-driven way to configure a router-side port forward without needing to set RADIANCE_PEER_EXTERNAL_PORT in the environment. Backend (Go side): - Core gains SetPeerManualPort(int) and GetPeerManualPort() — PatchSettings(PeerManualPortKey: ) and a typed read with koanf's float64-after-JSON-roundtrip behavior handled. - Two new //export FFI functions: setPeerManualPort(C.int) and getPeerManualPort() returning C.int. Frontend (Dart side): - lantern_generated_bindings.dart: hand-rolled bindings for the new exports (skipping ffigen for the prototype). - LanternCoreService interface, LanternFFIService impl, LanternService router, LanternPlatformService stub all gain setPeerManualPort / getPeerManualPort. Platform stub returns "not implemented" since the iOS/Android MethodChannel handlers aren't plumbed yet — degrades gracefully on those platforms. - New _AdvancedCard widget on the Share My Connection screen with an ExpansionTile (collapsed by default), containing _ManualPortField: loads the persisted port via getPeerManualPort, validates 1-65535, saves via setPeerManualPort, surfaces a SnackBar on success/failure. When set, displays a hint that toggling the share off-and-on is needed for the change to take effect (peer.Client.Start reads the setting once at start, doesn't watch it). Note on Unbounded: the disclosure dialog still references "Basic mode (Unbounded)" but Unbounded is not actually wired up on this branch — selecting it just sets local Dart state with no backend running. Real broflake/Unbounded integration follows when radiance#336 lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- lantern-core/core.go | 26 +++ lantern-core/ffi/ffi.go | 27 +++ .../share_my_connection.dart | 185 ++++++++++++++++++ lib/lantern/lantern_core_service.dart | 8 + lib/lantern/lantern_ffi_service.dart | 28 +++ lib/lantern/lantern_generated_bindings.dart | 20 ++ lib/lantern/lantern_platform_service.dart | 19 ++ lib/lantern/lantern_service.dart | 16 ++ 8 files changed, 329 insertions(+) diff --git a/lantern-core/core.go b/lantern-core/core.go index 83f6cc5d83..b7577c8061 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -163,6 +163,11 @@ type SmartRouting interface { type PeerShare interface { SetPeerShareEnabled(bool) error IsPeerShareEnabled() bool + // SetPeerManualPort persists the user's manually-configured router + // port forward (Advanced setting in the Share My Connection UI). + // 0 clears the override, restoring UPnP-discovered port behavior. + SetPeerManualPort(port int) error + GetPeerManualPort() int } type VPN interface { @@ -498,6 +503,27 @@ func (lc *LanternCore) IsPeerShareEnabled() bool { return b } +func (lc *LanternCore) SetPeerManualPort(port int) error { + if port < 0 || port > 65535 { + return fmt.Errorf("port %d out of range (0-65535)", port) + } + _, err := lc.client.PatchSettings(lc.ctx, settings.Settings{settings.PeerManualPortKey: port}) + return err +} + +func (lc *LanternCore) GetPeerManualPort() int { + // koanf typically stores numeric settings as float64 after JSON + // round-trip; handle both float64 and int paths so loads from disk + // and freshly-set values both work. + switch v := lc.settings()[settings.PeerManualPortKey].(type) { + case int: + return v + case float64: + return int(v) + } + return 0 +} + func (lc *LanternCore) IsTelemetryEnabled() bool { b, _ := lc.settings()[settings.TelemetryKey].(bool) return b diff --git a/lantern-core/ffi/ffi.go b/lantern-core/ffi/ffi.go index ca67526fdb..2f8ffe69d3 100644 --- a/lantern-core/ffi/ffi.go +++ b/lantern-core/ffi/ffi.go @@ -1375,6 +1375,33 @@ func isPeerProxyEnabled() C.int { return 0 } +// setPeerManualPort persists the manually-configured router port-forward +// for the Share My Connection peer-share feature. 0 clears the override, +// reverting to UPnP discovery on the next peer.Client.Start. +// +//export setPeerManualPort +func setPeerManualPort(port C.int) *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + if err := c.SetPeerManualPort(int(port)); err != nil { + return SendError(err) + } + return C.CString("ok") + }) +} + +//export getPeerManualPort +func getPeerManualPort() C.int { + c, _ := requireCore() + if c == nil { + return 0 + } + return C.int(c.GetPeerManualPort()) +} + //export getSplitTunnelState func getSplitTunnelState() *C.char { return runOnGoStack(func() *C.char { diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index f84116c675..fac3f01663 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -18,6 +18,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_earth_globe/flutter_earth_globe.dart'; import 'package:flutter_earth_globe/flutter_earth_globe_controller.dart'; import 'package:flutter_earth_globe/globe_coordinates.dart'; @@ -289,6 +290,8 @@ class ShareMyConnectionScreen extends HookConsumerWidget { ), const SizedBox(height: 8), _StatusCard(state: state, onToggle: () => notifier.toggle(context, ref)), + const SizedBox(height: 12), + const _AdvancedCard(), const SizedBox(height: 16), ], ), @@ -542,6 +545,188 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { } } +// ─── Advanced section ──────────────────────────────────────────────────────── + +/// _AdvancedCard exposes power-user knobs that don't belong in the +/// always-visible status card. Today: manual port forward (for users on +/// networks where UPnP doesn't work, who've configured a router-side +/// port forward by hand). +/// +/// Persisted via the existing FFI setPeerManualPort path; takes effect +/// on the next peer.Client.Start (i.e. next time the toggle is flipped +/// on after editing the field). +class _AdvancedCard extends HookConsumerWidget { + const _AdvancedCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textTheme = Theme.of(context).textTheme; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.black12), + ), + child: Theme( + // Strip the divider lines ExpansionTile draws by default — the + // container border already gives the section its own outline. + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + title: Text('Advanced', style: textTheme.labelLarge), + subtitle: Text( + 'For users whose router doesn\'t support UPnP', + style: textTheme.labelSmall, + ), + children: const [_ManualPortField()], + ), + ), + ); + } +} + +class _ManualPortField extends HookConsumerWidget { + const _ManualPortField(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textTheme = Theme.of(context).textTheme; + final controller = useTextEditingController(); + final loaded = useState(false); + final saving = useState(false); + final lastSaved = useState(null); + + // Load the persisted port once. We deliberately don't watch a + // provider here — the value rarely changes and a one-shot read + // matches the rest of the radianceSettingsProvider's eager-load + // pattern. + useEffect(() { + Future.microtask(() async { + final result = + await ref.read(lanternServiceProvider).getPeerManualPort(); + result.fold((_) => null, (port) { + if (port > 0) controller.text = port.toString(); + lastSaved.value = port; + }); + loaded.value = true; + }); + return null; + }, const []); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Manual port forward', + style: textTheme.labelLarge, + ), + const SizedBox(height: 4), + Text( + 'If your router doesn\'t support UPnP, configure a port forward ' + 'on your router and enter the port number here. Lantern will use ' + 'it as the external port instead of probing UPnP. Leave blank to ' + 'use UPnP (default).', + style: textTheme.bodySmall, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: controller, + keyboardType: const TextInputType.numberWithOptions( + decimal: false, + signed: false, + ), + decoration: InputDecoration( + labelText: 'Port', + hintText: 'e.g. 5698', + border: const OutlineInputBorder(), + isDense: true, + enabled: loaded.value && !saving.value, + ), + ), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: (loaded.value && !saving.value) + ? () => _save(ref, context, controller, saving, lastSaved) + : null, + child: saving.value + ? const SizedBox( + height: 16, width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ), + if (lastSaved.value != null && lastSaved.value! > 0) ...[ + const SizedBox(height: 8), + Text( + 'Currently set to port ${lastSaved.value}. Toggle Share My ' + 'Connection off and back on for the change to take effect.', + style: textTheme.bodySmall?.copyWith( + color: Theme.of(context).hintColor, + ), + ), + ], + ], + ); + } + + Future _save( + WidgetRef ref, + BuildContext context, + TextEditingController controller, + ValueNotifier saving, + ValueNotifier lastSaved, + ) async { + saving.value = true; + try { + final raw = controller.text.trim(); + int port = 0; + if (raw.isNotEmpty) { + port = int.tryParse(raw) ?? -1; + if (port < 1 || port > 65535) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Port must be between 1 and 65535')), + ); + } + return; + } + } + final result = + await ref.read(lanternServiceProvider).setPeerManualPort(port); + result.fold( + (err) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(err.localizedErrorMessage)), + ); + } + }, + (_) { + lastSaved.value = port; + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(port == 0 + ? 'Manual port cleared — using UPnP' + : 'Manual port set to $port'), + ), + ); + } + }, + ); + } finally { + saving.value = false; + } + } +} + // ─── Disclosure dialog ─────────────────────────────────────────────────────── class SmcDisclosureDialog extends StatelessWidget { diff --git a/lib/lantern/lantern_core_service.dart b/lib/lantern/lantern_core_service.dart index 124ad9b1d4..7d70f16e76 100644 --- a/lib/lantern/lantern_core_service.dart +++ b/lib/lantern/lantern_core_service.dart @@ -83,6 +83,14 @@ abstract class LanternCoreService { Future> isPeerProxyEnabled(); + /// Persists the manually-configured router port forward used as the + /// peer-share external port when set. Pass 0 to clear the override + /// and revert to UPnP-discovered port behavior. + Future> setPeerManualPort(int port); + + /// Returns the persisted manual port (0 if unset). + Future> getPeerManualPort(); + Future> isSmartRoutingEnabled(); Future> isTelemetryEnabled(); diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index 6c206cf5ef..0c67700472 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -1578,6 +1578,34 @@ class LanternFFIService implements LanternCoreService { } } + @override + Future> setPeerManualPort(int port) async { + try { + final result = await runInBackground(() async { + return _ffiService + .setPeerManualPort(port) + .cast() + .toDartString(); + }); + checkAPIError(result); + return right(unit); + } catch (e, st) { + appLogger.error('setPeerManualPort error: $e', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> getPeerManualPort() async { + try { + final res = _ffiService.getPeerManualPort(); + return right(res); + } catch (e, st) { + appLogger.error('getPeerManualPort error: $e', e, st); + return Left(e.toFailure()); + } + } + @override Future> isSmartRoutingEnabled() async { try { diff --git a/lib/lantern/lantern_generated_bindings.dart b/lib/lantern/lantern_generated_bindings.dart index 315ad4a52c..3b2b39f6b7 100644 --- a/lib/lantern/lantern_generated_bindings.dart +++ b/lib/lantern/lantern_generated_bindings.dart @@ -6295,6 +6295,26 @@ class LanternBindings { late final _isPeerProxyEnabled = _isPeerProxyEnabledPtr .asFunction(); + ffi.Pointer setPeerManualPort(int port) { + return _setPeerManualPort(port); + } + + late final _setPeerManualPortPtr = + _lookup Function(ffi.Int)>>( + 'setPeerManualPort', + ); + late final _setPeerManualPort = _setPeerManualPortPtr + .asFunction Function(int)>(); + + int getPeerManualPort() { + return _getPeerManualPort(); + } + + late final _getPeerManualPortPtr = + _lookup>('getPeerManualPort'); + late final _getPeerManualPort = _getPeerManualPortPtr + .asFunction(); + ffi.Pointer setSmartRoutingEnabled(int enabled) { return _setSmartRoutingEnabled(enabled); } diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 9774c1b7f4..05e4fdb2e5 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -314,6 +314,25 @@ class LanternPlatformService implements LanternCoreService { } } + // Manual port forward setting — only the FFI path is wired today; + // the iOS / Android MethodChannel handlers don't yet implement these + // methods. Stub returns "unsupported" rather than throwing so the + // Advanced UI degrades gracefully on platforms that don't (yet) plumb + // the setting through their tunnel-extension IPC. + @override + Future> setPeerManualPort(int port) async { + return Left(Failure( + error: 'setPeerManualPort: not implemented on this platform', + localizedErrorMessage: + 'Manual port forwarding is not yet available on this platform.', + )); + } + + @override + Future> getPeerManualPort() async { + return right(0); + } + @override Future> isSmartRoutingEnabled() async { try { diff --git a/lib/lantern/lantern_service.dart b/lib/lantern/lantern_service.dart index 9936cd4164..76ac21c8e4 100644 --- a/lib/lantern/lantern_service.dart +++ b/lib/lantern/lantern_service.dart @@ -810,6 +810,22 @@ class LanternService implements LanternCoreService { return _platformService.setPeerProxyEnabled(enabled); } + @override + Future> setPeerManualPort(int port) { + if (PlatformUtils.isFFISupported) { + return _ffiService.setPeerManualPort(port); + } + return _platformService.setPeerManualPort(port); + } + + @override + Future> getPeerManualPort() { + if (PlatformUtils.isFFISupported) { + return _ffiService.getPeerManualPort(); + } + return _platformService.getPeerManualPort(); + } + @override Future> isSmartRoutingEnabled() { if (PlatformUtils.isFFISupported) { From ec0a947ad620a08a6576273c38b341623ea72b0c Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 7 May 2026 18:49:08 -0600 Subject: [PATCH 12/35] Unbounded fully wired through to the SmC UI's "Basic mode" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end Unbounded integration on top of the radiance side: - Core gains SetUnboundedEnabled(bool) / IsUnboundedEnabled() — PatchSettings(UnboundedKey: ...) into the radiance settings store, picked up by radiance/unbounded's config-event subscription. - listenPeerConnectionEvents now subscribes to BOTH peer.ConnectionEvent (samizdat over UPnP / manual port — SmC mode) and unbounded.ConnectionEvent (broflake WebRTC — Unbounded mode), each forwarded as the same EventTypePeerConnection FlutterEvent. The globe sees a single unified stream and renders arcs identically regardless of which donor protocol produced the connection. - Two new //export FFI functions: setUnboundedEnabled, isUnboundedEnabled, with hand-rolled Dart bindings (skipping ffigen for the prototype). - LanternCoreService interface + FFI / Service / Platform impls all gain setUnboundedEnabled / isUnboundedEnabled. Platform stub returns "not implemented" for non-FFI platforms (iOS / Android) since their MethodChannel handlers aren't plumbed yet. - share_my_connection.dart's _start / _stop now actually call setUnboundedEnabled when the user picks Unbounded mode — so flipping the toggle and choosing "Basic mode (Unbounded)" in the disclosure dialog now starts the real broflake widget proxy, not just sets local Dart state. The broflake widget only actually runs when all three conditions hold: local opt-in (this toggle), server Features[UNBOUNDED] flag, and server-supplied UnboundedConfig. If the server hasn't rolled out the feature yet, the toggle persists the opt-in but the proxy stays inactive until the next /config response opts the user in. Co-Authored-By: Claude Opus 4.7 (1M context) --- lantern-core/core.go | 50 ++++++++++++++-- lantern-core/ffi/ffi.go | 30 ++++++++++ .../share_my_connection.dart | 59 +++++++++++++------ lib/lantern/lantern_core_service.dart | 7 +++ lib/lantern/lantern_ffi_service.dart | 28 +++++++++ lib/lantern/lantern_generated_bindings.dart | 20 +++++++ lib/lantern/lantern_platform_service.dart | 14 +++++ lib/lantern/lantern_service.dart | 16 +++++ 8 files changed, 201 insertions(+), 23 deletions(-) diff --git a/lantern-core/core.go b/lantern-core/core.go index b7577c8061..17a2fdcb66 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -23,6 +23,7 @@ import ( "github.com/getlantern/radiance/issue" "github.com/getlantern/radiance/peer" "github.com/getlantern/radiance/servers" + "github.com/getlantern/radiance/unbounded" "github.com/getlantern/radiance/vpn" "github.com/getlantern/lantern/lantern-core/apps" @@ -168,6 +169,13 @@ type PeerShare interface { // 0 clears the override, restoring UPnP-discovered port behavior. SetPeerManualPort(port int) error GetPeerManualPort() int + // SetUnboundedEnabled is the local opt-in for the broflake / + // Unbounded widget proxy ("Basic mode" in the SmC UI). The + // proxy actually runs only when this is true AND the server-side + // Features[unbounded] flag is on AND the server provides + // UnboundedConfig — see radiance/unbounded.shouldRunUnbounded. + SetUnboundedEnabled(bool) error + IsUnboundedEnabled() bool } type VPN interface { @@ -373,12 +381,20 @@ func (lc *LanternCore) listenDataCapEvents() { } } -// listenPeerConnectionEvents forwards samizdat accept/close events from the -// radiance peer client to the Flutter side via the existing FlutterEvent -// emitter, so the Share My Connection globe can render arcs as remote -// clients connect to the local peer's inbound. Subscription is process- -// lifetime; events.Subscribe is decoupled from peer.Client lifecycle and -// silently delivers nothing while no peer is active. +// listenPeerConnectionEvents forwards inbound accept/close events from +// either of the two donor protocols (samizdat-over-UPnP "Share My +// Connection" and broflake "Unbounded") to the Flutter side via the +// existing FlutterEvent emitter, so the same globe widget can render +// arcs without caring which protocol produced them. Subscription is +// process-lifetime; events.Subscribe silently delivers nothing while +// no peer / widget is active. +// +// The wire format unifies both protocols on a single event type +// (EventTypePeerConnection) with a {state, source} payload. Unbounded +// has a workerIdx in addition to source IP — surfaced as part of the +// JSON in case the Dart side eventually wants to disambiguate same-IP +// reconnects (broflake's WebRTC sessions are short and same-IP churn +// is more common than for SmC's long-lived TCP). func (lc *LanternCore) listenPeerConnectionEvents() { events.Subscribe(func(evt peer.ConnectionEvent) { jsonBytes, err := json.Marshal(map[string]any{ @@ -391,6 +407,18 @@ func (lc *LanternCore) listenPeerConnectionEvents() { } lc.notifyFlutter(EventTypePeerConnection, string(jsonBytes)) }) + events.Subscribe(func(evt unbounded.ConnectionEvent) { + jsonBytes, err := json.Marshal(map[string]any{ + "state": evt.State, + "source": evt.Addr, + "workerIdx": evt.WorkerIdx, + }) + if err != nil { + slog.Error("marshal unbounded connection event", "error", err) + return + } + lc.notifyFlutter(EventTypePeerConnection, string(jsonBytes)) + }) } ///////////////// @@ -524,6 +552,16 @@ func (lc *LanternCore) GetPeerManualPort() int { return 0 } +func (lc *LanternCore) SetUnboundedEnabled(enabled bool) error { + _, err := lc.client.PatchSettings(lc.ctx, settings.Settings{settings.UnboundedKey: enabled}) + return err +} + +func (lc *LanternCore) IsUnboundedEnabled() bool { + b, _ := lc.settings()[settings.UnboundedKey].(bool) + return b +} + func (lc *LanternCore) IsTelemetryEnabled() bool { b, _ := lc.settings()[settings.TelemetryKey].(bool) return b diff --git a/lantern-core/ffi/ffi.go b/lantern-core/ffi/ffi.go index 2f8ffe69d3..8bf32d90d9 100644 --- a/lantern-core/ffi/ffi.go +++ b/lantern-core/ffi/ffi.go @@ -1402,6 +1402,36 @@ func getPeerManualPort() C.int { return C.int(c.GetPeerManualPort()) } +// setUnboundedEnabled is the local opt-in for the broflake / Unbounded +// widget proxy ("Basic mode" in the SmC UI). The widget actually runs +// only when this is true AND the server-side Features[unbounded] flag +// is on AND the server provides UnboundedConfig — flipping this to +// true on a network where the server hasn't enabled the feature is a +// no-op until the next /config response opts the user in. +// +//export setUnboundedEnabled +func setUnboundedEnabled(enabled C.int) *C.char { + return runOnGoStack(func() *C.char { + c, errStr := requireCore() + if errStr != nil { + return errStr + } + if err := c.SetUnboundedEnabled(enabled != 0); err != nil { + return SendError(err) + } + return C.CString("ok") + }) +} + +//export isUnboundedEnabled +func isUnboundedEnabled() C.int { + c, _ := requireCore() + if c != nil && c.IsUnboundedEnabled() { + return 1 + } + return 0 +} + //export getSplitTunnelState func getSplitTunnelState() *C.char { return runOnGoStack(func() *C.char { diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index fac3f01663..8631a8a536 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -159,29 +159,54 @@ class ShareNotifier extends Notifier { totalCount: 0, ); _startEventSubscription(widgetRef); - if (mode == ShareMode.smc) { - // Flip the radiance peer-proxy setting; LocalBackend.PatchSettings - // routes that into peer.Client.Start, which spins up the UPnP map, - // registers with lantern-cloud, runs the samizdat inbound, and (via - // the lantern-box peerconn listener radiance/peer/peer.go now sets) - // emits ConnectionEvents that ride the radiance event bus → core.go - // listenPeerConnectionEvents → FlutterEvent → our Dart subscription. - await widgetRef - .read(radianceSettingsProvider.notifier) - .setPeerProxy(true); + switch (mode) { + case ShareMode.smc: + // Flip the radiance peer-proxy setting; LocalBackend.PatchSettings + // routes that into peer.Client.Start, which spins up the UPnP map + // (or honours PeerManualPortKey), registers with lantern-cloud, + // runs the samizdat inbound, and (via the lantern-box peerconn + // listener) emits ConnectionEvents that ride the radiance event + // bus → core.go listenPeerConnectionEvents → FlutterEvent → our + // Dart subscription. + await widgetRef + .read(radianceSettingsProvider.notifier) + .setPeerProxy(true); + break; + case ShareMode.unbounded: + // Unbounded is the broflake / WebRTC widget-proxy mode. Local + // opt-in only — actual run state also depends on the server's + // Features[unbounded] flag and supplied UnboundedConfig (see + // radiance/unbounded/unbounded.go shouldRunUnbounded). When + // running, broflake's OnConnectionChange callback emits + // unbounded.ConnectionEvent → forwarded by lantern-core as the + // same EventTypePeerConnection FlutterEvent the SmC path uses, + // so this Dart subscription consumes both protocols uniformly. + await widgetRef + .read(lanternServiceProvider) + .setUnboundedEnabled(true); + break; + case ShareMode.off: + break; } - // Unbounded mode is UI-only on this branch; broflake plumbing follows - // when radiance#336 lands. } Future _stop(WidgetRef widgetRef) async { _stopEventSubscription(); - final wasSmc = state.mode == ShareMode.smc; + final priorMode = state.mode; state = const ShareState(); - if (wasSmc) { - await widgetRef - .read(radianceSettingsProvider.notifier) - .setPeerProxy(false); + switch (priorMode) { + case ShareMode.smc: + await widgetRef + .read(radianceSettingsProvider.notifier) + .setPeerProxy(false); + break; + case ShareMode.unbounded: + await widgetRef + .read(lanternServiceProvider) + .setUnboundedEnabled(false); + break; + case ShareMode.off: + break; } } diff --git a/lib/lantern/lantern_core_service.dart b/lib/lantern/lantern_core_service.dart index 7d70f16e76..bc7c3abf99 100644 --- a/lib/lantern/lantern_core_service.dart +++ b/lib/lantern/lantern_core_service.dart @@ -91,6 +91,13 @@ abstract class LanternCoreService { /// Returns the persisted manual port (0 if unset). Future> getPeerManualPort(); + /// Local opt-in for the broflake / Unbounded widget proxy ("Basic + /// mode" in the Share My Connection UI). Actual run state also + /// depends on server feature-flag and config availability. + Future> setUnboundedEnabled(bool enabled); + + Future> isUnboundedEnabled(); + Future> isSmartRoutingEnabled(); Future> isTelemetryEnabled(); diff --git a/lib/lantern/lantern_ffi_service.dart b/lib/lantern/lantern_ffi_service.dart index 0c67700472..d59c892c47 100644 --- a/lib/lantern/lantern_ffi_service.dart +++ b/lib/lantern/lantern_ffi_service.dart @@ -1606,6 +1606,34 @@ class LanternFFIService implements LanternCoreService { } } + @override + Future> setUnboundedEnabled(bool enabled) async { + try { + final result = await runInBackground(() async { + return _ffiService + .setUnboundedEnabled(enabled ? 1 : 0) + .cast() + .toDartString(); + }); + checkAPIError(result); + return right(unit); + } catch (e, st) { + appLogger.error('setUnboundedEnabled error: $e', e, st); + return Left(e.toFailure()); + } + } + + @override + Future> isUnboundedEnabled() async { + try { + final res = _ffiService.isUnboundedEnabled(); + return right(res != 0); + } catch (e, st) { + appLogger.error('isUnboundedEnabled error: $e', e, st); + return Left(e.toFailure()); + } + } + @override Future> isSmartRoutingEnabled() async { try { diff --git a/lib/lantern/lantern_generated_bindings.dart b/lib/lantern/lantern_generated_bindings.dart index 3b2b39f6b7..89265acdcc 100644 --- a/lib/lantern/lantern_generated_bindings.dart +++ b/lib/lantern/lantern_generated_bindings.dart @@ -6315,6 +6315,26 @@ class LanternBindings { late final _getPeerManualPort = _getPeerManualPortPtr .asFunction(); + ffi.Pointer setUnboundedEnabled(int enabled) { + return _setUnboundedEnabled(enabled); + } + + late final _setUnboundedEnabledPtr = + _lookup Function(ffi.Int)>>( + 'setUnboundedEnabled', + ); + late final _setUnboundedEnabled = _setUnboundedEnabledPtr + .asFunction Function(int)>(); + + int isUnboundedEnabled() { + return _isUnboundedEnabled(); + } + + late final _isUnboundedEnabledPtr = + _lookup>('isUnboundedEnabled'); + late final _isUnboundedEnabled = _isUnboundedEnabledPtr + .asFunction(); + ffi.Pointer setSmartRoutingEnabled(int enabled) { return _setSmartRoutingEnabled(enabled); } diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index 05e4fdb2e5..c21180b2d7 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -333,6 +333,20 @@ class LanternPlatformService implements LanternCoreService { return right(0); } + @override + Future> setUnboundedEnabled(bool enabled) async { + return Left(Failure( + error: 'setUnboundedEnabled: not implemented on this platform', + localizedErrorMessage: + 'Unbounded is not yet available on this platform.', + )); + } + + @override + Future> isUnboundedEnabled() async { + return right(false); + } + @override Future> isSmartRoutingEnabled() async { try { diff --git a/lib/lantern/lantern_service.dart b/lib/lantern/lantern_service.dart index 76ac21c8e4..390109b54e 100644 --- a/lib/lantern/lantern_service.dart +++ b/lib/lantern/lantern_service.dart @@ -826,6 +826,22 @@ class LanternService implements LanternCoreService { return _platformService.getPeerManualPort(); } + @override + Future> setUnboundedEnabled(bool enabled) { + if (PlatformUtils.isFFISupported) { + return _ffiService.setUnboundedEnabled(enabled); + } + return _platformService.setUnboundedEnabled(enabled); + } + + @override + Future> isUnboundedEnabled() { + if (PlatformUtils.isFFISupported) { + return _ffiService.isUnboundedEnabled(); + } + return _platformService.isUnboundedEnabled(); + } + @override Future> isSmartRoutingEnabled() { if (PlatformUtils.isFFISupported) { From c2201a621e6052299c1ab671b39f25772f596c2d Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 8 May 2026 09:28:22 -0600 Subject: [PATCH 13/35] macOS: wire setPeerManualPort + setUnboundedEnabled through MethodChannel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlatformUtils.isFFISupported is Windows-or-Linux only — macOS routes through MethodChannel because the radiance backend runs inside the network extension, not the main app process. Without these handlers, the Advanced "Manual port forward" save and the Unbounded mode selection both hit the platform-service stub and surface "not yet available on this platform" SnackBars even though the underlying Core methods exist. Brings macOS to feature parity with Windows/Linux for the SmC stack: Already wired (existed): setPeerProxyEnabled / isPeerProxyEnabled → MobileSetPeerShareEnabled / MobileIsPeerShareEnabled Wired in this commit: setPeerManualPort / getPeerManualPort → MobileSetPeerManualPort / MobileGetPeerManualPort setUnboundedEnabled / isUnboundedEnabled → MobileSetUnboundedEnabled / MobileIsUnboundedEnabled After the next `make macos-release` (gomobile-bind regenerates Liblantern.xcframework with the four new symbols), the Share My Connection UI works end-to-end on macOS: - Toggle on, choose Full mode → peer.Client.Start, samizdat inbound - Choose Basic mode → unbounded.SetEnabled, broflake widget runs when the server's Features[unbounded] flag + config arrive - Advanced section save → port persisted, used as the manual forward override on next peer.Client.Start iOS / Android still don't have these handlers; SmC is also gated behind PlatformUtils.isDesktop in vpn_setting.dart so the tile isn't visible there. Mobile support is a separate UX pass — the "share my connection" mental model is different on cellular (sharing data plan, not residential bandwidth) and UPnP isn't applicable. Co-Authored-By: Claude Opus 4.7 (1M context) --- lantern-core/mobile/mobile.go | 51 ++++++++++++++++++++ lib/lantern/lantern_platform_service.dart | 58 ++++++++++++++++------- macos/Runner/Handlers/MethodHandler.swift | 48 +++++++++++++++++++ 3 files changed, 140 insertions(+), 17 deletions(-) diff --git a/lantern-core/mobile/mobile.go b/lantern-core/mobile/mobile.go index c04c2929e0..328f2a35f4 100644 --- a/lantern-core/mobile/mobile.go +++ b/lantern-core/mobile/mobile.go @@ -220,6 +220,57 @@ func IsPeerShareEnabled() bool { return ok } +// SetPeerManualPort persists the manually-configured router port forward +// the user has configured for the Share My Connection feature. Pass 0 +// to clear and revert to UPnP-discovered port behavior. Surfaced +// through the macOS / iOS / Android MethodChannel handler so platforms +// running radiance inside a network extension (where the main app +// process can't reach the FFI directly) can still drive the setting. +func SetPeerManualPort(port int) error { + slog.Info("peer-share: SetPeerManualPort", "port", port) + return withCore(func(c lanterncore.Core) error { + return c.SetPeerManualPort(port) + }) +} + +// GetPeerManualPort returns the currently-persisted manual port (0 if +// unset). Same MethodChannel rationale as SetPeerManualPort. +func GetPeerManualPort() int { + v, err := withCoreR(func(c lanterncore.Core) (int, error) { + return c.GetPeerManualPort(), nil + }) + if err != nil { + return 0 + } + return v +} + +// SetUnboundedEnabled is the local opt-in for the broflake / Unbounded +// widget proxy ("Basic mode" in the SmC UI). Surfaced through the +// MethodChannel so platforms running radiance inside a network +// extension (macOS, eventually iOS) can drive the setting from the +// main app process. +func SetUnboundedEnabled(enabled bool) error { + slog.Info("unbounded: SetUnboundedEnabled", "enabled", enabled) + return withCore(func(c lanterncore.Core) error { + return c.SetUnboundedEnabled(enabled) + }) +} + +// IsUnboundedEnabled returns the current local opt-in state for +// Unbounded. Note: actual run state also depends on the server +// Features[unbounded] flag and supplied UnboundedConfig — this just +// reports the persisted local toggle. +func IsUnboundedEnabled() bool { + ok, err := withCoreR(func(c lanterncore.Core) (bool, error) { + return c.IsUnboundedEnabled(), nil + }) + if err != nil { + return false + } + return ok +} + func IsSmartRoutingEnabled() bool { ok, err := withCoreR(func(c lanterncore.Core) (bool, error) { return c.IsSmartRoutingEnabled(), nil diff --git a/lib/lantern/lantern_platform_service.dart b/lib/lantern/lantern_platform_service.dart index c21180b2d7..730d6ead68 100644 --- a/lib/lantern/lantern_platform_service.dart +++ b/lib/lantern/lantern_platform_service.dart @@ -314,37 +314,61 @@ class LanternPlatformService implements LanternCoreService { } } - // Manual port forward setting — only the FFI path is wired today; - // the iOS / Android MethodChannel handlers don't yet implement these - // methods. Stub returns "unsupported" rather than throwing so the - // Advanced UI degrades gracefully on platforms that don't (yet) plumb - // the setting through their tunnel-extension IPC. + // Manual port forward setting — wired through MethodChannel to the + // platform-specific handler (Swift on macOS calls Mobile.SetPeerManualPort, + // similar pattern needed for iOS / Android when the user-facing toggle + // ships there). @override Future> setPeerManualPort(int port) async { - return Left(Failure( - error: 'setPeerManualPort: not implemented on this platform', - localizedErrorMessage: - 'Manual port forwarding is not yet available on this platform.', - )); + try { + await _methodChannel.invokeMethod('setPeerManualPort', { + 'port': port, + }); + return right(unit); + } catch (e, st) { + appLogger.error('setPeerManualPort failed', e, st); + return Left(e.toFailure()); + } } @override Future> getPeerManualPort() async { - return right(0); + try { + final res = await _methodChannel.invokeMethod('getPeerManualPort'); + return right(res ?? 0); + } catch (e, st) { + appLogger.error('getPeerManualPort failed', e, st); + return Left(e.toFailure()); + } } + // Unbounded toggle wired through MethodChannel to the platform-specific + // handler (Swift on macOS calls Mobile.SetUnboundedEnabled). Mobile + // platforms (iOS / Android) don't implement these handlers yet — they + // will throw MissingPluginException, which `e.toFailure()` translates + // into a localized error so the Advanced UI degrades cleanly. @override Future> setUnboundedEnabled(bool enabled) async { - return Left(Failure( - error: 'setUnboundedEnabled: not implemented on this platform', - localizedErrorMessage: - 'Unbounded is not yet available on this platform.', - )); + try { + await _methodChannel.invokeMethod('setUnboundedEnabled', { + 'enabled': enabled, + }); + return right(unit); + } catch (e, st) { + appLogger.error('setUnboundedEnabled failed', e, st); + return Left(e.toFailure()); + } } @override Future> isUnboundedEnabled() async { - return right(false); + try { + final res = await _methodChannel.invokeMethod('isUnboundedEnabled'); + return right(res ?? false); + } catch (e, st) { + appLogger.error('isUnboundedEnabled failed', e, st); + return Left(e.toFailure()); + } } @override diff --git a/macos/Runner/Handlers/MethodHandler.swift b/macos/Runner/Handlers/MethodHandler.swift index 6eaa33d4b2..7c3600d938 100644 --- a/macos/Runner/Handlers/MethodHandler.swift +++ b/macos/Runner/Handlers/MethodHandler.swift @@ -255,6 +255,26 @@ class MethodHandler { let enabled = data?["enabled"] as? Bool ?? false self.setPeerProxyEnabled(result: result, enabled: enabled) + case "setPeerManualPort": + let data = call.arguments as? [String: Any] + let port = data?["port"] as? Int ?? 0 + self.setPeerManualPort(result: result, port: port) + + case "getPeerManualPort": + Task { + await MainActor.run { result(Int(MobileGetPeerManualPort())) } + } + + case "setUnboundedEnabled": + let data = call.arguments as? [String: Any] + let enabled = data?["enabled"] as? Bool ?? false + self.setUnboundedEnabled(result: result, enabled: enabled) + + case "isUnboundedEnabled": + Task { + await MainActor.run { result(MobileIsUnboundedEnabled()) } + } + case "updateTelemetryEvents": guard let consent: Bool = self.decodeValue(from: call.arguments, result: result) else { return @@ -1176,6 +1196,34 @@ class MethodHandler { } } + func setPeerManualPort(result: @escaping FlutterResult, port: Int) { + Task { + var error: NSError? + MobileSetPeerManualPort(port, &error) + if let error { + await self.handleFlutterError(error, result: result, code: "SET_PEER_MANUAL_PORT_ERROR") + return + } + await MainActor.run { + result("ok") + } + } + } + + func setUnboundedEnabled(result: @escaping FlutterResult, enabled: Bool) { + Task { + var error: NSError? + MobileSetUnboundedEnabled(enabled, &error) + if let error { + await self.handleFlutterError(error, result: result, code: "SET_UNBOUNDED_ENABLED_ERROR") + return + } + await MainActor.run { + result("ok") + } + } + } + func updateTelemetryEvents(consent: Bool, result: @escaping FlutterResult) { Task { var error: NSError? From d7caedad866f22fa990331b7c86c9988d9f7f1a4 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 8 May 2026 09:31:59 -0600 Subject: [PATCH 14/35] share-my-connection: toggle honors Advanced manual port before UPnP probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dart-side toggle was running its mocked UPnP probe (a coin flip) without first checking whether the user had configured a manual port in Advanced settings. When the coin landed "no UPnP" the user got silently dropped into Unbounded mode despite having explicitly set up a port forward — defeating the whole point of the Advanced setting. Resolution order on enable is now: 1. settings.PeerManualPortKey is set (via Advanced UI): → straight to SmC mode, no UPnP probe, no disclosure dialog. Configuring a manual port forward is an explicit user-driven SmC opt-in; they wouldn't set it up if they weren't sure they wanted to share via the residential-IP path. 2. UPnP probe (mocked for now): → SmC if available + disclosure accepted, Unbounded if declined or unavailable. The radiance side already had the right precedence in peer.Client.Start's NewForwarder factory (settings > env var > UPnP); this just stops the Dart toggle from short-circuiting to Unbounded before the radiance side ever gets called. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../share_my_connection.dart | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 8631a8a536..b62c98bfdf 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -102,6 +102,15 @@ class ShareNotifier extends Notifier { /// Toggle entry point. Caller passes its BuildContext so we can show the /// disclosure modal inline, and a WidgetRef so we can drive the radiance /// peer-share toggle. + /// + /// Resolution order on enable: + /// 1. If the user has set a manual port in Advanced settings, that + /// is an explicit opt-in — go straight to SmC mode. No UPnP + /// probe, no disclosure (user already crossed that line by + /// configuring the port forward on their router). + /// 2. Otherwise probe UPnP. If UPnP works AND the user accepts + /// the SmC disclosure, run SmC. Decline → Unbounded. + /// 3. UPnP unavailable → Unbounded fallback. Future toggle(BuildContext context, WidgetRef widgetRef) async { if (state.active || state.probing) { await _stop(widgetRef); @@ -110,10 +119,21 @@ class ShareNotifier extends Notifier { state = state.copyWith(probing: true); - // MOCK: real impl will FFI into radiance/portforward to probe UPnP. - // Coin-flip the result so the demo exercises both the SmC and Unbounded - // paths across runs; flip to `true` for the SmC path while iterating on - // the disclosure copy. + // Manual port forward bypasses both the UPnP probe and the SmC + // disclosure dialog. Configuring a port in Advanced is an explicit + // user-driven SmC opt-in — they wouldn't have set it up if they + // weren't sure they wanted to share via the residential-IP path. + final manualPortRes = + await widgetRef.read(lanternServiceProvider).getPeerManualPort(); + final manualPort = manualPortRes.fold((_) => 0, (p) => p); + if (manualPort > 0) { + await _start(widgetRef, ShareMode.smc); + return; + } + + // MOCK: real UPnP probe via FFI is not yet wired; coin-flip the + // result so the demo exercises both paths across runs without a + // manual port set. await Future.delayed(const Duration(milliseconds: 1500)); final upnpAvailable = Random().nextBool(); if (!upnpAvailable) { From bfaeea212df055869e4dcf1fcbab574fd38be11e Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 8 May 2026 11:05:22 -0600 Subject: [PATCH 15/35] mobile: sanitize errors before returning to gomobile bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunOffCgoStack normalizes any non-nil error to a plain errorString with a guaranteed non-empty, valid-UTF-8 Error() message before handing it back to the gomobile-exported caller. Without this, a SIGABRT crashes the Lantern process when any mobile-exported function returns an error whose string contains non-UTF-8 bytes. Reproduced when toggling Share My Connection on while the prod /v1/peer/register endpoint returned 404 with a body whose bytes weren't valid UTF-8 (likely a gzipped or otherwise binary error page from the upstream LB). The chain that triggers the crash: *Error{Message: <404 body bytes>} → Error.Error() = "ipc: status 500: ... body=" → withCore returns this through gomobile → -[Universeerror initWithRef:] auto-generated wrapper: self = [super initWithDomain:@"go" code:1 userInfo:@{NSLocalizedDescriptionKey: [self error]}]; → [self error] calls go_seq_to_objc_string() → [[NSString alloc] initWithBytesNoCopy:bytes length:N encoding:NSUTF8StringEncoding freeWhenDone:YES] → returns nil for non-UTF-8 input → @{...: nil} expands to +[NSDictionary dictionaryWithObjects:forKeys:count:] with objects[0] == nil → NSInvalidArgumentException → SIGABRT Crash signature on macOS: *** -[__NSPlaceholderDictionary initWithObjects:forKeys:count:]: attempt to insert nil object from objects[0] ... -[Universeerror initWithRef:] + 192 MobileSetPeerShareEnabled + 160 Centralizing the sanitization in RunOffCgoStack covers every Mobile* function that funnels its body through withCore (essentially all of mobile.go), so we don't have to thread fixes through individual exports. Co-Authored-By: Claude Opus 4.7 (1M context) --- lantern-core/utils/gostack.go | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/lantern-core/utils/gostack.go b/lantern-core/utils/gostack.go index 99d6b5b660..75515a23e2 100644 --- a/lantern-core/utils/gostack.go +++ b/lantern-core/utils/gostack.go @@ -1,9 +1,12 @@ package utils import ( + "errors" "fmt" "log/slog" "runtime/debug" + "strings" + "unicode/utf8" ) // RunOffCgoStack executes fn on a new goroutine and returns its result. @@ -16,6 +19,17 @@ import ( // // If fn panics, the panic is recovered and a zero value + error are returned // instead of blocking the caller forever. +// +// Returned errors are normalized to a plain *errorString with a guaranteed +// non-empty, valid-UTF-8 message before crossing back into gomobile's +// objc bridge. The bridge wraps non-nil Go errors as a Universeerror whose +// initWithRef calls [NSString initWithBytesNoCopy: ... encoding:UTF8] on the +// raw error bytes; that returns nil for invalid UTF-8 (e.g. a gzipped 404 +// page, or a binary blob from an upstream LB), and the dictionary literal +// `@{NSLocalizedDescriptionKey: nil}` then aborts the app with +// "attempt to insert nil object from objects[0]". Sanitizing here means every +// gomobile-exported function that funnels through RunOffCgoStack is safe by +// construction, regardless of what shape of error its callee returns. func RunOffCgoStack[T any](fn func() (T, error)) (T, error) { type result struct { val T @@ -34,5 +48,19 @@ func RunOffCgoStack[T any](fn func() (T, error)) (T, error) { ch <- result{val: v, err: err} }() r := <-ch - return r.val, r.err + return r.val, sanitizeForGomobile(r.err) +} + +func sanitizeForGomobile(err error) error { + if err == nil { + return nil + } + msg := err.Error() + if !utf8.ValidString(msg) { + msg = strings.ToValidUTF8(msg, "?") + } + if msg == "" { + msg = "unknown error" + } + return errors.New(msg) } From c0508d2d26fe0b8f817f26c926d86b200152701f Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 13:45:23 -0600 Subject: [PATCH 16/35] share-my-connection: surface radiance peer phase events to the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toggle today flips active/inactive with a multi-second gap between "on" and "Active — sharing" while radiance walks the Start lifecycle (port map → IP detect → register → libbox start → verify). To the user this looks hung. Adds granular status text driven by the new peer StatusEvent stream from radiance/peer (companion PR github.com/getlantern/radiance/pull/). lantern-core/core.go: + EventTypePeerStatus = "peer-status" + listenPeerStatusEvents() forwards peer.StatusEvent (whose .Status field already has JSON tags for phase, error, active, etc.) as a FlutterEvent so the Dart side gets per-stage notifications. share_my_connection.dart: + SharePhase enum mirrors radiance Phase strings; .fromWire() maps backward-compatibly so unknown future phases default to idle. + ShareState carries phase + errorMessage; _handlePeerStatus folds incoming events into state. + _StatusCard renders phase-specific labels (Opening port… → Registering… → Verifying… → Sharing) and the error message on the failure terminal state. Co-Authored-By: Claude Opus 4.7 (1M context) --- lantern-core/core.go | 32 ++++- .../share_my_connection.dart | 109 +++++++++++++++++- 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/lantern-core/core.go b/lantern-core/core.go index 17a2fdcb66..2b0688985b 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -44,7 +44,14 @@ const ( // {"state": +1|-1, "source": "ip:port"}; consumers extract the IP for // geo-lookup or rate-limit attribution. EventTypePeerConnection EventType = "peer-connection" - DefaultLogLevel = "trace" + // EventTypePeerStatus signals a peer.Client lifecycle phase change + // (mapping_port → registering → verifying → serving on the way up, + // stopping → idle on the way down, error on failure). Message is the + // JSON-marshalled peer.Status struct. The Dart side switches on + // .phase to render progress text and on .error to surface + // diagnostics on the failure path. + EventTypePeerStatus EventType = "peer-status" + DefaultLogLevel = "trace" ) // LanternCore wraps an IPC client and provides the interface expected by the FFI and mobile layers. @@ -267,6 +274,7 @@ func (lc *LanternCore) initialize(opts *utils.Opts, eventEmitter utils.FlutterEv go lc.listenConfigEvents() go lc.listenDataCapEvents() go lc.listenPeerConnectionEvents() + go lc.listenPeerStatusEvents() go lc.fetchUserDataIfNeeded() slog.Debug("LanternCore initialized successfully") @@ -421,6 +429,28 @@ func (lc *LanternCore) listenPeerConnectionEvents() { }) } +// listenPeerStatusEvents forwards peer.Client lifecycle phase changes to +// the Flutter side. radiance's peer module emits one StatusEvent per +// stage during Start (mapping_port → detecting_ip → registering → +// starting_proxy → verifying → serving) and during Stop (stopping → +// idle), plus an "error" terminal event with Status.Error populated on +// failure. The Dart side renders each phase as user-facing progress +// text instead of a single active/inactive flip. +// +// Message body is the JSON-marshalled peer.Status — the struct already +// carries phase, error, active, sharing_since, external_ip, +// external_port, route_id with stable JSON tags. +func (lc *LanternCore) listenPeerStatusEvents() { + events.Subscribe(func(evt peer.StatusEvent) { + jsonBytes, err := json.Marshal(evt.Status) + if err != nil { + slog.Error("marshal peer status event", "error", err) + return + } + lc.notifyFlutter(EventTypePeerStatus, string(jsonBytes)) + }) +} + ///////////////// // VPN // ///////////////// diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index b62c98bfdf..a642097011 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -43,12 +43,54 @@ import 'package:lantern/lantern/lantern_service_notifier.dart'; /// higher risk; gated on a one-time disclosure) enum ShareMode { off, unbounded, smc } +/// Lifecycle phase for SmC mode, sourced from radiance peer.Status.Phase +/// via the `peer-status` FlutterEvent. Stable strings — must stay in +/// sync with radiance/peer/peer.go's Phase constants. +/// +/// idle — nothing running +/// mappingPort — UPnP / manual port mapping in flight +/// detectingIp — public-IP detection +/// registering — POST /v1/peer/register against lantern-cloud +/// startingProxy — libbox samizdat inbound coming up +/// verifying — POST /v1/peer/verify, lantern-cloud is dialing back +/// serving — peer is live and assignable to censored clients +/// stopping — teardown in progress +/// error — Start failed; SharePhase.errorMessage holds the cause +enum SharePhase { + idle, + mappingPort, + detectingIp, + registering, + startingProxy, + verifying, + serving, + stopping, + error; + + static SharePhase fromWire(String? s) => switch (s) { + 'mapping_port' => SharePhase.mappingPort, + 'detecting_ip' => SharePhase.detectingIp, + 'registering' => SharePhase.registering, + 'starting_proxy' => SharePhase.startingProxy, + 'verifying' => SharePhase.verifying, + 'serving' => SharePhase.serving, + 'stopping' => SharePhase.stopping, + 'error' => SharePhase.error, + _ => SharePhase.idle, + }; +} + class ShareState { final bool active; final bool probing; final ShareMode mode; final int activeCount; final int totalCount; + // SmC-only: granular Start/Stop phase from radiance peer.Status. For + // Unbounded mode this stays SharePhase.idle (no equivalent staged + // lifecycle on the broflake side yet). + final SharePhase phase; + final String? errorMessage; const ShareState({ this.active = false, @@ -56,6 +98,8 @@ class ShareState { this.mode = ShareMode.off, this.activeCount = 0, this.totalCount = 0, + this.phase = SharePhase.idle, + this.errorMessage, }); ShareState copyWith({ @@ -64,6 +108,8 @@ class ShareState { ShareMode? mode, int? activeCount, int? totalCount, + SharePhase? phase, + String? errorMessage, }) => ShareState( active: active ?? this.active, @@ -71,6 +117,8 @@ class ShareState { mode: mode ?? this.mode, activeCount: activeCount ?? this.activeCount, totalCount: totalCount ?? this.totalCount, + phase: phase ?? this.phase, + errorMessage: errorMessage ?? this.errorMessage, ); } @@ -248,6 +296,10 @@ class ShareNotifier extends Notifier { .read(lanternServiceProvider) .watchAppEvents() .listen((event) { + if (event.eventType == 'peer-status') { + _handlePeerStatus(event.message); + return; + } if (event.eventType != 'peer-connection') return; try { final payload = jsonDecode(event.message) as Map; @@ -299,6 +351,26 @@ class ShareNotifier extends Notifier { _sourceToWorker.clear(); _workerSeq = 0; } + + // Parses a `peer-status` FlutterEvent and folds the new phase / error + // into ShareState. Payload is the JSON-marshalled radiance peer.Status + // (see lantern-core/core.go EventTypePeerStatus). Phase strings come + // from radiance/peer/peer.go's Phase constants; we map them through + // SharePhase.fromWire so an unknown future phase falls back to idle + // instead of crashing the consumer. + void _handlePeerStatus(String message) { + try { + final payload = jsonDecode(message) as Map; + final phase = SharePhase.fromWire(payload['phase'] as String?); + final errMsg = payload['error'] as String?; + state = state.copyWith( + phase: phase, + errorMessage: (errMsg == null || errMsg.isEmpty) ? null : errMsg, + ); + } catch (e) { + debugPrint('share-my-connection: bad peer-status event: $e'); + } + } } final shareProvider = @@ -356,11 +428,40 @@ class _StatusCard extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - final modeLabel = switch (state.mode) { - ShareMode.off => state.probing ? 'Probing your network…' : 'Off', - ShareMode.unbounded => + // Status text source-of-truth, in priority order: + // 1. Off and not probing → "Off" + // 2. Probing UPnP locally → "Probing your network…" + // 3. SmC mode → granular phase from radiance peer.Status. The + // backend emits one phase per stage during Start so the user + // sees real progress instead of "Active" for several seconds. + // 4. Unbounded mode → static "Active — Unbounded" (no equivalent + // staged lifecycle on the broflake side yet). + final modeLabel = switch ((state.mode, state.phase)) { + (ShareMode.off, _) => + state.probing ? 'Probing your network…' : 'Off', + (ShareMode.unbounded, _) => 'Active — sharing via Unbounded (WebRTC)', - ShareMode.smc => + (ShareMode.smc, SharePhase.mappingPort) => + 'Opening port on your router…', + (ShareMode.smc, SharePhase.detectingIp) => + 'Detecting your public IP…', + (ShareMode.smc, SharePhase.registering) => + 'Registering with Lantern…', + (ShareMode.smc, SharePhase.startingProxy) => + 'Starting local proxy…', + (ShareMode.smc, SharePhase.verifying) => + 'Verifying connectivity…', + (ShareMode.smc, SharePhase.serving) => + 'Sharing — ready to serve users in censored regions', + (ShareMode.smc, SharePhase.stopping) => 'Stopping…', + (ShareMode.smc, SharePhase.error) => + state.errorMessage != null + ? "Couldn't share: ${state.errorMessage}" + : "Couldn't share — try toggling again", + // SmC active but no phase yet (e.g. very first frame after toggle + // before the backend's first event arrives) — fall back to the + // legacy active label so the UI isn't blank. + (ShareMode.smc, SharePhase.idle) => 'Active — sharing via Share My Connection (residential proxy)', }; From 0ee485396ebff0788dc0fe32d616ef46448f689f Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 14:30:05 -0600 Subject: [PATCH 17/35] core: instrument peer-connection subscriber to pair with radiance breadcrumb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If radiance's peer listener logs "forwarding" but this subscriber doesn't log "forwarding to Flutter", events.Emit is reaching no subscriber — the events bus is broken between Emit and Subscribe (process boundary in gomobile builds, etc.). If both log but Flutter sees nothing, the FlutterEvent bridge is the culprit. Spam-friendly: ~1 line per accept/close, bounded by peer inbound throughput. Co-Authored-By: Claude Opus 4.7 (1M context) --- lantern-core/core.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lantern-core/core.go b/lantern-core/core.go index 2b0688985b..ee41cb79ae 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -405,6 +405,16 @@ func (lc *LanternCore) listenDataCapEvents() { // is more common than for SmC's long-lived TCP). func (lc *LanternCore) listenPeerConnectionEvents() { events.Subscribe(func(evt peer.ConnectionEvent) { + // Diagnostic: every time this fires, we know events.Emit reached + // the subscriber. Pairs with the breadcrumb in radiance peer.go's + // peerconn listener — if the radiance side logs "forwarding" but + // we don't see this, the events bus is dropping between Emit and + // Subscribe (process boundary in gomobile builds, etc.). If both + // fire but Flutter sees nothing, the FlutterEvent bridge is the + // culprit. Spam-friendly: ~1 per accept/close, bounded by peer + // inbound throughput. + slog.Info("peer-connection subscriber: forwarding to Flutter", + "state", evt.State, "source", evt.Source) jsonBytes, err := json.Marshal(map[string]any{ "state": evt.State, "source": evt.Source, From 3230651036fe2e6480385335c5dd045810113944 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 15:21:29 -0600 Subject: [PATCH 18/35] core: log listenPeerConnectionEvents goroutine entry One-shot diagnostic: if we see radiance peer listener firing but never this line, the goroutine that calls events.Subscribe was never started. Co-Authored-By: Claude Opus 4.7 (1M context) --- lantern-core/core.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lantern-core/core.go b/lantern-core/core.go index ee41cb79ae..ba297f2dd9 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -404,6 +404,12 @@ func (lc *LanternCore) listenDataCapEvents() { // reconnects (broflake's WebRTC sessions are short and same-IP churn // is more common than for SmC's long-lived TCP). func (lc *LanternCore) listenPeerConnectionEvents() { + // One-shot diagnostic: confirms this goroutine was actually started by + // the LanternCore init path. If we see "peer listener: forwarding..." + // from radiance but never see this line, listenPeerConnectionEvents + // was never called — init bailed out earlier or the goroutine was + // dropped. + slog.Info("peer-connection subscriber: registering events.Subscribe") events.Subscribe(func(evt peer.ConnectionEvent) { // Diagnostic: every time this fires, we know events.Emit reached // the subscriber. Pairs with the breadcrumb in radiance peer.go's From 9209aef2b0726999e339b3800629ac020110746b Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 11 May 2026 15:47:54 -0600 Subject: [PATCH 19/35] core: consume peer events over IPC SSE instead of in-process events.Subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The events.Subscribe path was broken — radiance/peer emits in the lanternd process, but lantern-core's subscriber lives in Liblantern. Process boundary means two separate events package instances; subscribers=0 at every emit. Replace both listenPeerStatusEvents and listenPeerConnectionEvents (peer half) with the IPC client's PeerStatusEvents / PeerConnectionEvents SSE stream methods. The unbounded.ConnectionEvent half stays on events.Subscribe — broflake-as-library runs in the consumer process today and doesn't hit the cross-process gap. Co-Authored-By: Claude Opus 4.7 (1M context) --- lantern-core/core.go | 59 +++++++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/lantern-core/core.go b/lantern-core/core.go index ba297f2dd9..69dcff5575 100644 --- a/lantern-core/core.go +++ b/lantern-core/core.go @@ -404,33 +404,33 @@ func (lc *LanternCore) listenDataCapEvents() { // reconnects (broflake's WebRTC sessions are short and same-IP churn // is more common than for SmC's long-lived TCP). func (lc *LanternCore) listenPeerConnectionEvents() { - // One-shot diagnostic: confirms this goroutine was actually started by - // the LanternCore init path. If we see "peer listener: forwarding..." - // from radiance but never see this line, listenPeerConnectionEvents - // was never called — init bailed out earlier or the goroutine was - // dropped. - slog.Info("peer-connection subscriber: registering events.Subscribe") - events.Subscribe(func(evt peer.ConnectionEvent) { - // Diagnostic: every time this fires, we know events.Emit reached - // the subscriber. Pairs with the breadcrumb in radiance peer.go's - // peerconn listener — if the radiance side logs "forwarding" but - // we don't see this, the events bus is dropping between Emit and - // Subscribe (process boundary in gomobile builds, etc.). If both - // fire but Flutter sees nothing, the FlutterEvent bridge is the - // culprit. Spam-friendly: ~1 per accept/close, bounded by peer - // inbound throughput. - slog.Info("peer-connection subscriber: forwarding to Flutter", - "state", evt.State, "source", evt.Source) - jsonBytes, err := json.Marshal(map[string]any{ - "state": evt.State, - "source": evt.Source, + // peer.ConnectionEvent: subscribe via the IPC client's SSE stream. + // The events package's globals are process-scoped — events.Emit in + // lanternd (where radiance/peer runs) doesn't reach events.Subscribe + // in Liblantern. The /peer/connection/events SSE endpoint in + // radiance/ipc/server.go bridges the two processes. + go func() { + err := lc.client.PeerConnectionEvents(lc.ctx, func(evt peer.ConnectionEvent) { + jsonBytes, err := json.Marshal(map[string]any{ + "state": evt.State, + "source": evt.Source, + }) + if err != nil { + slog.Error("marshal peer connection event", "error", err) + return + } + lc.notifyFlutter(EventTypePeerConnection, string(jsonBytes)) }) - if err != nil { - slog.Error("marshal peer connection event", "error", err) - return + if err != nil && lc.ctx.Err() == nil { + slog.Error("peer-connection event stream exited unexpectedly", "error", err) } - lc.notifyFlutter(EventTypePeerConnection, string(jsonBytes)) - }) + }() + // unbounded.ConnectionEvent stays on in-process events.Subscribe for + // now. Unbounded runs in the same process as the consumer in mobile + // builds (broflake-as-library); the desktop path doesn't yet have a + // gomobile-bridged Unbounded peer, so the cross-process gap doesn't + // hit here today. Worth revisiting if Unbounded ever moves out of + // process. events.Subscribe(func(evt unbounded.ConnectionEvent) { jsonBytes, err := json.Marshal(map[string]any{ "state": evt.State, @@ -457,7 +457,11 @@ func (lc *LanternCore) listenPeerConnectionEvents() { // carries phase, error, active, sharing_since, external_ip, // external_port, route_id with stable JSON tags. func (lc *LanternCore) listenPeerStatusEvents() { - events.Subscribe(func(evt peer.StatusEvent) { + // Same cross-process bridging story as listenPeerConnectionEvents: the + // peer.StatusEvent emits live in lanternd, so subscribing in this + // process via events.Subscribe gets us nothing. /peer/status/events + // SSE in radiance/ipc/server.go is the canonical source. + err := lc.client.PeerStatusEvents(lc.ctx, func(evt peer.StatusEvent) { jsonBytes, err := json.Marshal(evt.Status) if err != nil { slog.Error("marshal peer status event", "error", err) @@ -465,6 +469,9 @@ func (lc *LanternCore) listenPeerStatusEvents() { } lc.notifyFlutter(EventTypePeerStatus, string(jsonBytes)) }) + if err != nil && lc.ctx.Err() == nil { + slog.Error("peer-status event stream exited unexpectedly", "error", err) + } } ///////////////// From 76d738aceacffb51dc35b976bace33457822e0a0 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 09:56:44 -0600 Subject: [PATCH 20/35] SmC: real per-peer geo, on-globe heart burst, arc reversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Geo: peerLookup switched from geo.getiantem.org/ (returns 404 for arbitrary IPs — every peer collapsed to the IR-fallback center) to ipwho.is (HTTPS, no auth, city-level lat/lon + country name + flag emoji). PeerLookup now returns PeerGeo with a real, unique location per peer. - Event model: UnboundedConnectionEvent carries country name, flag emoji, coords, and an isReplay flag. - Notifier: ref-counts streams per TCP peer so the arc persists until the peer's last H2 stream closes (samizdat multiplexes many streams over one conn); resolves geo async then emits enriched events; replayCurrentPeers() seeds the globe with existing peers when the user navigates to SmC mid-stream; emits synthetic -1's on toggle-off so arcs don't orphan when peer.Client.Stop suppresses the box.Close cascade. - Globe: arcs linger 5s past last -1 so brief URL-test probes still register; coords jittered ±2° per workerIdx hash so multiple peers in the same city fan out instead of overlapping; arc direction reversed (censored user → uncensored peer) so the dash animation reads as traffic arriving at us. - Heart burst: on-globe animation anchored at peer coords via Point.labelBuilder (lib projects 3D→2D for us). Uses the actual assets from getlantern/unbounded — explosion.json Lottie + the inline FF5A79 heart SVG path via CustomPainter. 4.6s burst + 4.2s fading country label below. - StatusCard: small info_outline tooltip explaining that most events are short URL-test liveness probes (601 of ~700 CONNECTs in a measured session were to api.iantem.io — clients probing peer reachability before sending real traffic). Co-Authored-By: Claude Opus 4.7 (1M context) --- assets/unbounded/explosion.json | 1 + .../models/unbounded_connection_event.dart | 18 + lib/core/services/geo_lookup_service.dart | 70 ++- .../share_my_connection.dart | 413 ++++++++++++++++-- pubspec.lock | 24 + pubspec.yaml | 1 + 6 files changed, 477 insertions(+), 50 deletions(-) create mode 100644 assets/unbounded/explosion.json diff --git a/assets/unbounded/explosion.json b/assets/unbounded/explosion.json new file mode 100644 index 0000000000..1773e7f105 --- /dev/null +++ b/assets/unbounded/explosion.json @@ -0,0 +1 @@ +{"nm":"F","h":502,"w":420,"meta":{"g":"LottieFiles Figma v42"},"layers":[{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[187.06,388.01],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[187.06,388.01],"t":114},{"s":[187.06,388.01],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[12.84],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[12.84],"t":114},{"s":[12.84],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":1},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[286.35,305.11],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[286.35,305.11],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[286.35,305.11],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[286.35,305.11],"t":114},{"s":[286.35,305.11],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-15],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-15],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-15],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-15],"t":114},{"s":[-15],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":2},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[57,292.33],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[57,292.33],"t":114},{"s":[57,292.33],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[13.56],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[13.56],"t":114},{"s":[13.56],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":3},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[89,362.45],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[89,362.45],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[89,362.45],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[89,362.45],"t":114},{"s":[89,362.45],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0.03},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":4},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[347,349.67],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[347,349.67],"t":114},{"s":[347,349.67],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":5},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[363,193.71],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[363,193.71],"t":114},{"s":[363,193.71],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-10.18],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-10.18],"t":114},{"s":[-10.18],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":6},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[41,180.93],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[41,180.93],"t":114},{"s":[41,180.93],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-7.58],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-7.58],"t":114},{"s":[-7.58],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":7},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[189.5,216.92],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[189.5,216.92],"t":114},{"s":[189.5,216.92],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":8},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[270.35,139.3],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[270.35,139.3],"t":114},{"s":[270.35,139.3],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-8.33],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-8.33],"t":114},{"s":[-8.33],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":9},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[249,317.89],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[249,317.89],"t":114},{"s":[249,317.89],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[19.7],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[19.7],"t":114},{"s":[19.7],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":10},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[89,96.56],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[89,96.56],"t":114},{"s":[89,96.56],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":11},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[272.03,46.69],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[272.03,46.69],"t":114},{"s":[272.03,46.69],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":12},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[347,71],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[347,71],"t":114},{"s":[347,71],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":13},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[152.62,62.88],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[152.62,62.88],"t":114},{"s":[152.62,62.88],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[20.16],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[20.16],"t":114},{"s":[20.16],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":14},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[108.69,186.58],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[108.69,186.58],"t":114},{"s":[108.69,186.58],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[20.43],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[20.43],"t":114},{"s":[20.43],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":15},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[397.01,263.98],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[397.01,263.98],"t":114},{"s":[397.01,263.98],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-9.8],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[-9.8],"t":114},{"s":[-9.8],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":16},{"ty":4,"nm":"V","sr":1,"st":0,"op":138,"ip":0,"hasMask":false,"ao":0,"ks":{"a":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[16,12.78],"t":114},{"s":[16,12.78],"t":137}]},"s":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100,100],"t":114},{"s":[100,100],"t":137}]},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[121,437.23],"t":114},{"s":[121,437.23],"t":137}]},"r":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[0],"t":114},{"s":[0],"t":137}]},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[0],"t":137}]}},"shapes":[{"ty":"sh","nm":"","d":1,"ks":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":114},{"s":[{"c":true,"i":[[0,0],[1.01,-4.53],[3.3,-8.84],[-1.28,-1.44],[3.68,9.7]],"o":[[-3.41,-9.06],[-1.39,-4.79],[-3.68,9.86],[1.28,-1.17],[0,0]],"v":[[31.5,5.42],[16,4.94],[0.5,5.42],[16,25.56],[31.5,5.42]]}],"t":137}]}},{"ty":"fl","nm":"","c":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[1,0.353,0.475],"t":114},{"s":[1,0.353,0.475],"t":137}]},"r":1,"o":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":0},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":90},{"o":{"x":0,"y":0},"i":{"x":0.58,"y":1},"s":[100],"t":114},{"s":[100],"t":137}]}}],"ind":17}],"v":"5.7.0","fr":30,"op":136.53,"ip":0,"assets":[]} \ No newline at end of file diff --git a/lib/core/models/unbounded_connection_event.dart b/lib/core/models/unbounded_connection_event.dart index eb643f255a..bcd040c515 100644 --- a/lib/core/models/unbounded_connection_event.dart +++ b/lib/core/models/unbounded_connection_event.dart @@ -1,13 +1,31 @@ +import 'package:flutter_earth_globe/globe_coordinates.dart'; + /// Represents a consumer connection change from the broflake widget proxy. class UnboundedConnectionEvent { final int state; // 1 = connected, -1 = disconnected final int workerIdx; final String addr; // IP address + // Geo fields are populated by ShareNotifier after peer lookup. Empty on + // legacy events and on -1 frames (where only workerIdx matters for the + // globe to remove the arc). + final String countryName; + final String countryCode; + final String flagEmoji; + final GlobeCoordinates? coordinates; + // True for synthetic events the notifier emits to seed a newly-mounted + // globe with peers that connected before the screen opened. Lets the UI + // suppress the "new connection from " burst for replays. + final bool isReplay; UnboundedConnectionEvent({ required this.state, required this.workerIdx, required this.addr, + this.countryName = '', + this.countryCode = '', + this.flagEmoji = '', + this.coordinates, + this.isReplay = false, }); factory UnboundedConnectionEvent.fromJson(Map json) { diff --git a/lib/core/services/geo_lookup_service.dart b/lib/core/services/geo_lookup_service.dart index 50150fe375..110aa38a8a 100644 --- a/lib/core/services/geo_lookup_service.dart +++ b/lib/core/services/geo_lookup_service.dart @@ -3,11 +3,38 @@ import 'dart:convert'; import 'package:flutter_earth_globe/globe_coordinates.dart'; import 'package:http/http.dart' as http; +/// Result of a peer geo lookup. Country/flag default to empty when the +/// lookup fails; coordinates default to a centre-of-the-globe sentinel. +class PeerGeo { + const PeerGeo({ + required this.coordinates, + required this.countryName, + required this.countryCode, + required this.flagEmoji, + }); + + final GlobeCoordinates coordinates; + final String countryName; + final String countryCode; + final String flagEmoji; + + static const unknown = PeerGeo( + coordinates: GlobeCoordinates(0, 0), + countryName: '', + countryCode: '', + flagEmoji: '', + ); +} + class GeoLookupService { - static const _geoUrl = 'https://geo.getiantem.org'; + static const _selfUrl = 'https://geo.getiantem.org'; + // ipwho.is: HTTPS, no auth, 10k req/month free. Returns country + lat/lon + // + flag emoji in one shot. + static const _peerUrl = 'https://ipwho.is'; - // ISO country code → approximate centre coordinates - static const _countries = { + // ISO country code → approximate centre coordinates. Used as a fallback + // when ipwho.is doesn't return city-level coords. + static const _countryCenters = { 'AF': (lat: 33.0, lng: 65.0), 'AL': (lat: 41.0, lng: 20.0), 'DZ': (lat: 28.0, lng: 3.0), @@ -133,7 +160,7 @@ class GeoLookupService { }; static GlobeCoordinates _isoToCoords(String iso) { - final c = _countries[iso] ?? _countries['US']!; + final c = _countryCenters[iso] ?? _countryCenters['US']!; return GlobeCoordinates(c.lat, c.lng); } @@ -141,7 +168,7 @@ class GeoLookupService { static Future selfLookup() async { try { final response = await http - .get(Uri.parse('$_geoUrl/')) + .get(Uri.parse('$_selfUrl/')) .timeout(const Duration(seconds: 5)); if (response.statusCode == 200) { final data = jsonDecode(response.body) as Map; @@ -154,20 +181,31 @@ class GeoLookupService { return _isoToCoords('US'); } - /// Looks up the country for a peer [ip] address. - static Future peerLookup(String ip) async { + /// Looks up country, flag, and coordinates for a peer [ip] address. + /// Returns [PeerGeo.unknown] on any failure so callers can suppress the + /// arc / banner rather than displaying a wrong country. + static Future peerLookup(String ip) async { try { final response = await http - .get(Uri.parse('$_geoUrl/$ip')) + .get(Uri.parse('$_peerUrl/$ip')) .timeout(const Duration(seconds: 5)); - if (response.statusCode == 200) { - final data = jsonDecode(response.body) as Map; - final iso = - (data['Country'] as Map?)?['IsoCode'] as String? ?? - 'IR'; - return _isoToCoords(iso); - } + if (response.statusCode != 200) return PeerGeo.unknown; + final data = jsonDecode(response.body) as Map; + if (data['success'] != true) return PeerGeo.unknown; + final iso = (data['country_code'] as String?) ?? ''; + final lat = (data['latitude'] as num?)?.toDouble(); + final lng = (data['longitude'] as num?)?.toDouble(); + final coords = (lat != null && lng != null) + ? GlobeCoordinates(lat, lng) + : _isoToCoords(iso); + final flagObj = data['flag'] as Map?; + return PeerGeo( + coordinates: coords, + countryName: (data['country'] as String?) ?? '', + countryCode: iso, + flagEmoji: (flagObj?['emoji'] as String?) ?? '', + ); } catch (_) {} - return _isoToCoords('IR'); + return PeerGeo.unknown; } } diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index a642097011..3a047afc57 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -26,6 +26,7 @@ import 'package:flutter_earth_globe/point.dart'; import 'package:flutter_earth_globe/point_connection.dart'; import 'package:flutter_earth_globe/point_connection_style.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lottie/lottie.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/unbounded_connection_event.dart'; import 'package:lantern/core/services/geo_lookup_service.dart'; @@ -124,6 +125,15 @@ class ShareState { // ─── Notifier (mock-backed) ────────────────────────────────────────────────── +class _PeerArc { + _PeerArc(this.workerIdx) : streamCount = 1; + final int workerIdx; + int streamCount; + // Geo is resolved async after the first +1 lands. Until then the peer is + // tracked but no arc is emitted — avoids a flash of "unknown" arcs. + PeerGeo? geo; +} + class ShareNotifier extends Notifier { // Persisted in real impl; in-process for the prototype so the disclosure // re-fires on app restart and is easy to demo. @@ -131,7 +141,10 @@ class ShareNotifier extends Notifier { StreamSubscription? _appEventSub; int _workerSeq = 0; - final Map _sourceToWorker = {}; + // Per-peer arc + active-stream count. samizdat multiplexes many H2 streams + // over one TCP conn, all sharing the same RemoteAddr — ref-count so the arc + // persists until the peer's LAST stream closes, not its first. + final Map _peerArcs = {}; final _eventController = StreamController.broadcast(); @@ -291,7 +304,7 @@ class ShareNotifier extends Notifier { // already use. void _startEventSubscription(WidgetRef widgetRef) { - _sourceToWorker.clear(); + _peerArcs.clear(); _appEventSub = widgetRef .read(lanternServiceProvider) .watchAppEvents() @@ -310,29 +323,38 @@ class ShareNotifier extends Notifier { if (ip.isEmpty) return; if (eventState == 1) { - // Each (source IP) gets a stable worker idx so the matching - // disconnect can find the arc to remove. Repeated +1 from the - // same source (re-connect after disconnect) gets a new idx. - if (_sourceToWorker.containsKey(ip)) return; + final existing = _peerArcs[ip]; + if (existing != null) { + existing.streamCount++; + return; + } final widx = _workerSeq++; - _sourceToWorker[ip] = widx; - _eventController.add(UnboundedConnectionEvent( - state: 1, - workerIdx: widx, - addr: ip, - )); + final arc = _PeerArc(widx); + _peerArcs[ip] = arc; state = state.copyWith( activeCount: state.activeCount + 1, totalCount: state.totalCount + 1, ); + // Resolve country async. Emit the +1 only after lookup so the + // globe can render the arc at the right coords and the UI can + // surface the country name in the connection banner. + unawaited(_resolveAndEmit(ip, arc)); } else if (eventState == -1) { - final widx = _sourceToWorker.remove(ip); - if (widx == null) return; - _eventController.add(UnboundedConnectionEvent( - state: -1, - workerIdx: widx, - addr: '', - )); + final entry = _peerArcs[ip]; + if (entry == null) return; + entry.streamCount--; + if (entry.streamCount > 0) return; + _peerArcs.remove(ip); + // Only emit -1 if we already emitted a +1 for this peer (i.e. + // the geo lookup completed). Otherwise the globe never saw it + // and a -1 with no preceding +1 would just be noise. + if (entry.geo != null) { + _eventController.add(UnboundedConnectionEvent( + state: -1, + workerIdx: entry.workerIdx, + addr: '', + )); + } state = state.copyWith( activeCount: max(0, state.activeCount - 1), ); @@ -345,10 +367,75 @@ class ShareNotifier extends Notifier { }); } + Future _resolveAndEmit(String ip, _PeerArc arc) async { + PeerGeo geo; + try { + geo = await GeoLookupService.peerLookup(ip); + } catch (_) { + geo = PeerGeo.unknown; + } + // Peer may have disconnected before the lookup returned. The map + // entry's identity (workerIdx) is the cheapest check. + final current = _peerArcs[ip]; + if (current == null || current.workerIdx != arc.workerIdx) return; + // Skip arcs we couldn't geo-locate. The peer is still counted in + // activeCount, but we don't draw a wrong-country arc. + if (geo.countryCode.isEmpty) return; + arc.geo = geo; + _eventController.add(UnboundedConnectionEvent( + state: 1, + workerIdx: arc.workerIdx, + addr: ip, + countryName: geo.countryName, + countryCode: geo.countryCode, + flagEmoji: geo.flagEmoji, + coordinates: geo.coordinates, + )); + } + + /// Replays a synthetic +1 for every currently-active peer that has a + /// resolved geo. Callers (e.g. the globe widget when it mounts after + /// the user navigates into the screen) get a one-shot seed of the + /// current world state so they don't render an empty globe despite + /// active connections. Replayed events have isReplay=true so the UI + /// can suppress the "new connection" burst. + void replayCurrentPeers() { + for (final entry in _peerArcs.entries) { + final arc = entry.value; + final geo = arc.geo; + if (geo == null) continue; + _eventController.add(UnboundedConnectionEvent( + state: 1, + workerIdx: arc.workerIdx, + addr: entry.key, + countryName: geo.countryName, + countryCode: geo.countryCode, + flagEmoji: geo.flagEmoji, + coordinates: geo.coordinates, + isReplay: true, + )); + } + } + void _stopEventSubscription() { + // Synthesize -1 for every active peer BEFORE killing the source + // stream. peer.Client.Stop on the Go side suppresses the box.Close + // disconnect cascade (correct — avoids a flood of post-Stop noise), + // so without this loop the globe would never see -1's for peers + // that were live at toggle-time. Their arcs would orphan and rotate + // with the globe indefinitely. With this loop, the globe sees real + // -1's and runs them through the normal linger-then-remove path. + for (final arc in _peerArcs.values) { + if (arc.geo == null) continue; + _eventController.add(UnboundedConnectionEvent( + state: -1, + workerIdx: arc.workerIdx, + addr: '', + )); + } _appEventSub?.cancel(); _appEventSub = null; - _sourceToWorker.clear(); + _peerArcs.clear(); _workerSeq = 0; } @@ -515,11 +602,44 @@ class _StatusCard extends StatelessWidget { const SizedBox(height: 12), const Divider(height: 1), const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, + Stack( children: [ - _Stat(label: 'Active now', value: '${state.activeCount}'), - _Stat(label: 'Total today', value: '${state.totalCount}'), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _Stat(label: 'Active now', value: '${state.activeCount}'), + _Stat(label: 'Total today', value: '${state.totalCount}'), + ], + ), + Positioned( + top: 0, + right: 0, + child: Tooltip( + triggerMode: TooltipTriggerMode.tap, + waitDuration: const Duration(milliseconds: 200), + showDuration: const Duration(seconds: 8), + preferBelow: false, + margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 10), + textStyle: const TextStyle(color: Colors.white, fontSize: 12), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8), + ), + message: + 'Most connections are short liveness probes — Lantern ' + 'clients periodically check that this peer is reachable ' + 'before sending real traffic. A quick burst from many ' + 'locations is normal; an arc that lingers represents an ' + 'actual user session.', + child: Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).hintColor, + ), + ), + ), ], ), ], @@ -574,6 +694,15 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { StreamSubscription? _eventSub; GlobeCoordinates? _originCoords; + // Pending arc removals: peer goes idle → we don't yank the arc + // immediately so brief URL-test probes (which dominate samizdat-peer + // traffic) still register visually. Timer is cancelled if the same + // workerIdx +1's again before it fires. + final Map _pendingRemovals = {}; + static const _arcLinger = Duration(seconds: 5); + // Matches the explosion.json timeline (4.55s at 30fps) so the Lottie + // plays to completion before the anchor point is removed. + static const _burstDuration = Duration(milliseconds: 4600); @override void initState() { @@ -583,15 +712,24 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { _applyTheme(); }; _initOrigin(); + // Subscribe BEFORE the replay call so we don't miss any concurrent + // +1 events. The broadcast stream delivers synchronously when added, + // but the replay events come from inside the same notifier so order + // is preserved. _eventSub = ref .read(shareProvider.notifier) .connectionEvents .listen(_handleEvent); + ref.read(shareProvider.notifier).replayCurrentPeers(); } @override void dispose() { _eventSub?.cancel(); + for (final t in _pendingRemovals.values) { + t.cancel(); + } + _pendingRemovals.clear(); _globeController.dispose(); super.dispose(); } @@ -618,21 +756,44 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { )); } - Future _handleEvent(UnboundedConnectionEvent event) async { - if (event.state == 1 && event.addr.isNotEmpty) { - await _addPeer(event.workerIdx, event.addr); + void _handleEvent(UnboundedConnectionEvent event) { + if (event.state == 1 && event.coordinates != null) { + // Cancel any lingering removal — same workerIdx is back. + _pendingRemovals.remove(event.workerIdx)?.cancel(); + _addPeer(event); + if (!event.isReplay) _announceArrival(event); } else if (event.state == -1) { - _removePeer(event.workerIdx); + // Linger the arc so brief connections still register visually. + _pendingRemovals[event.workerIdx]?.cancel(); + _pendingRemovals[event.workerIdx] = Timer(_arcLinger, () { + _pendingRemovals.remove(event.workerIdx); + if (!mounted) return; + _removePeer(event.workerIdx); + }); } } - Future _addPeer(int workerIdx, String addr) async { - final coords = await GeoLookupService.peerLookup(addr); + // Jitter coords by a workerIdx-derived offset so multiple peers from + // the same country don't draw arcs on top of each other. Hash-based so + // the same widx always lands in the same slot — no jitter drift on + // replay. + GlobeCoordinates _jittered(GlobeCoordinates base, int widx) { + final hash = widx * 2654435761; // Knuth multiplicative hash + final dLat = ((hash >> 4) & 0xff) / 255.0 * 4.0 - 2.0; // [-2, +2]° + final dLng = ((hash >> 12) & 0xff) / 255.0 * 4.0 - 2.0; + return GlobeCoordinates(base.latitude + dLat, base.longitude + dLng); + } + + void _addPeer(UnboundedConnectionEvent event) { if (!mounted) return; + final coords = _jittered(event.coordinates!, event.workerIdx); + // Arc direction is censored user → uncensored peer (us). The dash + // animation flows from start to end, so the visual "travel" reads + // as traffic arriving at our peer to escape censorship. _globeController.addPointConnection(PointConnection( - id: 'conn_$workerIdx', - start: _originCoords ?? const GlobeCoordinates(0, 0), - end: coords, + id: 'conn_${event.workerIdx}', + start: coords, + end: _originCoords ?? const GlobeCoordinates(0, 0), curveScale: .6, style: PointConnectionStyle( color: _arcColor, @@ -646,12 +807,39 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { ), )); _globeController.addPoint(Point( - id: 'peer_$workerIdx', + id: 'peer_${event.workerIdx}', coordinates: coords, style: PointStyle(color: _peerPointColor, size: 6), )); } + void _announceArrival(UnboundedConnectionEvent event) { + if (!mounted) return; + final coords = _jittered(event.coordinates!, event.workerIdx); + final burstId = 'burst_${event.workerIdx}_${DateTime.now().microsecondsSinceEpoch}'; + // Anchor a zero-size point at the peer's location. flutter_earth_globe + // calls labelBuilder with the projected on-screen position, so the + // burst widget renders directly on top of the peer's spot on the map + // (rotating + projecting along with the globe). + _globeController.addPoint(Point( + id: burstId, + coordinates: coords, + style: const PointStyle(color: Colors.transparent, size: 0.1), + isLabelVisible: true, + labelBuilder: (ctx, _, isHovering, isVisible) { + if (!isVisible) return const SizedBox.shrink(); + return _HeartBurst( + countryName: event.countryName, + flagEmoji: event.flagEmoji, + ); + }, + )); + Future.delayed(_burstDuration, () { + if (!mounted) return; + _globeController.removePoint(burstId); + }); + } + void _removePeer(int workerIdx) { _globeController.removePointConnection('conn_$workerIdx'); _globeController.removePoint('peer_$workerIdx'); @@ -691,6 +879,163 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { } } +// ─── Heart burst ───────────────────────────────────────────────────────────── + +/// Heart-burst anchored to a peer point on the globe — same visual as +/// unbounded.lantern.io. The pink heart is the inline SVG path from +/// `unbounded/ui/.../notification/explosion.tsx` (FF5A79 fill, 32×27 +/// viewBox); the burst is `unbounded/.../explosion.json` played once +/// via the `lottie` Flutter package. A small `flag country` label sits +/// just below and fades alongside. Self-disposes when the anchor point +/// is removed (~1.2s after creation by the caller). +class _HeartBurst extends StatefulWidget { + const _HeartBurst({this.countryName = '', this.flagEmoji = ''}); + + final String countryName; + final String flagEmoji; + + @override + State<_HeartBurst> createState() => _HeartBurstState(); +} + +class _HeartBurstState extends State<_HeartBurst> + with TickerProviderStateMixin { + // Drives only the label's fade in/out — the Lottie file owns its own + // animation timeline. + late final AnimationController _labelCtrl; + late final Animation _labelOpacity; + + // The lottie controller is driven by Lottie's own composition once + // it loads; we kick it off with goToAndPlay equivalent (forward). + AnimationController? _lottieCtrl; + + @override + void initState() { + super.initState(); + // Matches unbounded's notification auto-hide (3.5s visible + 0.7s + // fade-out) so the country label stays readable through the bulk + // of the explosion animation. + _labelCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 4200), + ); + _labelOpacity = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 15), + TweenSequenceItem(tween: ConstantTween(1.0), weight: 55), + TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 30), + ]).animate(_labelCtrl); + _labelCtrl.forward(); + } + + @override + void dispose() { + _labelCtrl.dispose(); + _lottieCtrl?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final label = widget.countryName.isEmpty + ? null + : '${widget.flagEmoji} ${widget.countryName}'.trim(); + return IgnorePointer( + child: SizedBox( + width: 120, + height: 120, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + // Lottie explosion sits behind the heart, sized larger so + // particle spray extends past the heart bounds. Matches + // unbounded's LottieWrapper sizing (420px wide canvas + // around a 32×27 heart) scaled for our anchor. + SizedBox( + width: 120, + height: 120, + child: Lottie.asset( + 'assets/unbounded/explosion.json', + repeat: false, + fit: BoxFit.contain, + onLoaded: (composition) { + _lottieCtrl = AnimationController( + vsync: this, + duration: composition.duration, + )..forward(); + setState(() {}); + }, + controller: _lottieCtrl, + ), + ), + // Heart SVG path — same coords as unbounded's inline SVG + // (FF5A79, 32x27 viewBox). + const SizedBox( + width: 32, + height: 27, + child: CustomPaint(painter: _HeartPainter()), + ), + if (label != null) + Positioned( + top: 78, + child: AnimatedBuilder( + animation: _labelCtrl, + builder: (context, _) => Opacity( + opacity: _labelOpacity.value, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Pink heart from `getlantern/unbounded` — exact SVG path coords +/// (viewBox 0 0 32 27, fill #FF5A79). +class _HeartPainter extends CustomPainter { + const _HeartPainter(); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = const Color(0xFFFF5A79); + final path = Path() + ..moveTo(31.5035, 5.87209) + ..cubicTo(28.0938, -3.18494, 17.0123, 0.864084, 16, 5.3926) + ..cubicTo(14.6148, 0.597701, 3.79965, -2.97183, 0.496497, 5.87209) + ..cubicTo(-3.17959, 15.7283, 14.7214, 24.5722, 16, 26.0107) + ..cubicTo(17.2786, 24.8386, 35.1796, 15.5684, 31.5035, 5.87209) + ..close(); + // Scale path from native 32x27 to the canvas size. + final scaled = path.transform(Matrix4.diagonal3Values( + size.width / 32.0, + size.height / 27.0, + 1.0, + ).storage); + canvas.drawPath(scaled, paint); + } + + @override + bool shouldRepaint(_HeartPainter oldDelegate) => false; +} + // ─── Advanced section ──────────────────────────────────────────────────────── /// _AdvancedCard exposes power-user knobs that don't belong in the diff --git a/pubspec.lock b/pubspec.lock index 204a170f99..0f69884877 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -1084,6 +1092,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + lottie: + dependency: "direct main" + description: + name: lottie + sha256: "8b6359a7422167014aa73ce763fa133fb832065dcc0ac4d1dec1f603a5cef7d0" + url: "https://pub.dev" + source: hosted + version: "3.3.3" matcher: dependency: transitive description: @@ -1292,6 +1308,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" process: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 919146cfe8..8c402c033d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: auto_route: ^11.1.0 #UI Utils flutter_earth_globe: ^2.2.0 + lottie: ^3.3.1 http: ^1.2.2 animated_toggle_switch: ^0.8.7 animated_text_kit: ^4.3.0 From db98526bc7e6ff40833cf681aa01cff131af0454 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 11:43:10 -0600 Subject: [PATCH 21/35] SmC: lift heart-burst off the globe into a floating toast Anchoring the burst to projected globe coords (via Point.labelBuilder) forced the widget to repaint every rotation frame, which made the globe rotation jittery. The burst is now a separate floating pill overlaid at the bottom of the globe area: - _ArrivalToast subscribes to ShareNotifier.connectionEvents, ignores replays, surfaces the current arrival in a slide-up + fade-in card. ValueKey on workerIdx forces AnimatedSwitcher to swap the widget when overlapping arrivals land so the Lottie restarts cleanly. - _HeartBurst is now just heart + Lottie, no country label, no globe anchor. The label moved into _ArrivalCard alongside the burst. - Removed _announceArrival (Point/labelBuilder pattern) and the burst anchor lifecycle. Globe rotation is smooth again. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../share_my_connection.dart | 308 ++++++++++-------- 1 file changed, 176 insertions(+), 132 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 3a047afc57..a4f3320274 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -490,7 +490,22 @@ class ShareMyConnectionScreen extends HookConsumerWidget { const SizedBox(height: 16), Expanded( flex: 3, - child: _GlobeView(), + child: Stack( + children: [ + Positioned.fill(child: _GlobeView()), + // Floating "new connection from X" toast — overlays the + // bottom of the globe area rather than the peer's exact + // location on the sphere. Anchoring to projected coords + // forced the burst to repaint every globe rotation + // frame, which made the rotation jittery. + const Positioned( + left: 0, + right: 0, + bottom: 8, + child: Center(child: _ArrivalToast()), + ), + ], + ), ), const SizedBox(height: 8), _StatusCard(state: state, onToggle: () => notifier.toggle(context, ref)), @@ -700,9 +715,6 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { // workerIdx +1's again before it fires. final Map _pendingRemovals = {}; static const _arcLinger = Duration(seconds: 5); - // Matches the explosion.json timeline (4.55s at 30fps) so the Lottie - // plays to completion before the anchor point is removed. - static const _burstDuration = Duration(milliseconds: 4600); @override void initState() { @@ -761,7 +773,6 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { // Cancel any lingering removal — same workerIdx is back. _pendingRemovals.remove(event.workerIdx)?.cancel(); _addPeer(event); - if (!event.isReplay) _announceArrival(event); } else if (event.state == -1) { // Linger the arc so brief connections still register visually. _pendingRemovals[event.workerIdx]?.cancel(); @@ -813,33 +824,6 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { )); } - void _announceArrival(UnboundedConnectionEvent event) { - if (!mounted) return; - final coords = _jittered(event.coordinates!, event.workerIdx); - final burstId = 'burst_${event.workerIdx}_${DateTime.now().microsecondsSinceEpoch}'; - // Anchor a zero-size point at the peer's location. flutter_earth_globe - // calls labelBuilder with the projected on-screen position, so the - // burst widget renders directly on top of the peer's spot on the map - // (rotating + projecting along with the globe). - _globeController.addPoint(Point( - id: burstId, - coordinates: coords, - style: const PointStyle(color: Colors.transparent, size: 0.1), - isLabelVisible: true, - labelBuilder: (ctx, _, isHovering, isVisible) { - if (!isVisible) return const SizedBox.shrink(); - return _HeartBurst( - countryName: event.countryName, - flagEmoji: event.flagEmoji, - ); - }, - )); - Future.delayed(_burstDuration, () { - if (!mounted) return; - _globeController.removePoint(burstId); - }); - } - void _removePeer(int workerIdx) { _globeController.removePointConnection('conn_$workerIdx'); _globeController.removePoint('peer_$workerIdx'); @@ -879,128 +863,123 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { } } -// ─── Heart burst ───────────────────────────────────────────────────────────── - -/// Heart-burst anchored to a peer point on the globe — same visual as -/// unbounded.lantern.io. The pink heart is the inline SVG path from -/// `unbounded/ui/.../notification/explosion.tsx` (FF5A79 fill, 32×27 -/// viewBox); the burst is `unbounded/.../explosion.json` played once -/// via the `lottie` Flutter package. A small `flag country` label sits -/// just below and fades alongside. Self-disposes when the anchor point -/// is removed (~1.2s after creation by the caller). -class _HeartBurst extends StatefulWidget { - const _HeartBurst({this.countryName = '', this.flagEmoji = ''}); +// ─── Arrival toast ─────────────────────────────────────────────────────────── - final String countryName; - final String flagEmoji; +/// Floating notification overlay shown under the globe when a new peer +/// arrives. Mirrors the unbounded.lantern.io notification pattern: +/// heart-burst on the left, `New connection from ` text on +/// the right. Slides up + fades in, auto-hides after ~3.5s. Listens +/// directly to ShareNotifier.connectionEvents so we don't depend on +/// the globe widget for triggering. +class _ArrivalToast extends ConsumerStatefulWidget { + const _ArrivalToast(); @override - State<_HeartBurst> createState() => _HeartBurstState(); + ConsumerState<_ArrivalToast> createState() => _ArrivalToastState(); } -class _HeartBurstState extends State<_HeartBurst> - with TickerProviderStateMixin { - // Drives only the label's fade in/out — the Lottie file owns its own - // animation timeline. - late final AnimationController _labelCtrl; - late final Animation _labelOpacity; - - // The lottie controller is driven by Lottie's own composition once - // it loads; we kick it off with goToAndPlay equivalent (forward). - AnimationController? _lottieCtrl; +class _ArrivalToastState extends ConsumerState<_ArrivalToast> { + StreamSubscription? _sub; + Timer? _hideTimer; + UnboundedConnectionEvent? _current; @override void initState() { super.initState(); - // Matches unbounded's notification auto-hide (3.5s visible + 0.7s - // fade-out) so the country label stays readable through the bulk - // of the explosion animation. - _labelCtrl = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 4200), - ); - _labelOpacity = TweenSequence([ - TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 15), - TweenSequenceItem(tween: ConstantTween(1.0), weight: 55), - TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 30), - ]).animate(_labelCtrl); - _labelCtrl.forward(); + _sub = ref + .read(shareProvider.notifier) + .connectionEvents + .listen(_onEvent); + } + + void _onEvent(UnboundedConnectionEvent event) { + if (event.state != 1 || event.isReplay) return; + if (event.countryName.isEmpty) return; + if (!mounted) return; + _hideTimer?.cancel(); + setState(() => _current = event); + _hideTimer = Timer(const Duration(milliseconds: 3500), () { + if (!mounted) return; + setState(() => _current = null); + }); } @override void dispose() { - _labelCtrl.dispose(); - _lottieCtrl?.dispose(); + _sub?.cancel(); + _hideTimer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { - final label = widget.countryName.isEmpty - ? null - : '${widget.flagEmoji} ${widget.countryName}'.trim(); + final event = _current; + return AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.4), end: Offset.zero) + .animate(CurvedAnimation(parent: anim, curve: Curves.easeOut)), + child: child, + ), + ), + child: event == null + ? const SizedBox.shrink(key: ValueKey('arrival-hidden')) + : _ArrivalCard( + // ValueKey forces AnimatedSwitcher to swap children when a + // new arrival lands while the previous toast is still up, + // so the Lottie restarts cleanly. + key: ValueKey('arrival-${event.workerIdx}'), + countryName: event.countryName, + flagEmoji: event.flagEmoji, + ), + ); + } +} + +class _ArrivalCard extends StatelessWidget { + const _ArrivalCard({ + super.key, + required this.countryName, + required this.flagEmoji, + }); + + final String countryName; + final String flagEmoji; + + @override + Widget build(BuildContext context) { return IgnorePointer( - child: SizedBox( - width: 120, - height: 120, - child: Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, + child: Container( + padding: const EdgeInsets.fromLTRB(10, 8, 16, 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(100), + border: Border.all(color: Colors.black12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - // Lottie explosion sits behind the heart, sized larger so - // particle spray extends past the heart bounds. Matches - // unbounded's LottieWrapper sizing (420px wide canvas - // around a 32×27 heart) scaled for our anchor. - SizedBox( - width: 120, - height: 120, - child: Lottie.asset( - 'assets/unbounded/explosion.json', - repeat: false, - fit: BoxFit.contain, - onLoaded: (composition) { - _lottieCtrl = AnimationController( - vsync: this, - duration: composition.duration, - )..forward(); - setState(() {}); - }, - controller: _lottieCtrl, + const SizedBox(width: 40, height: 40, child: _HeartBurst()), + const SizedBox(width: 12), + Text( + flagEmoji.isEmpty + ? 'New connection from $countryName' + : '$flagEmoji New connection from $countryName', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, ), ), - // Heart SVG path — same coords as unbounded's inline SVG - // (FF5A79, 32x27 viewBox). - const SizedBox( - width: 32, - height: 27, - child: CustomPaint(painter: _HeartPainter()), - ), - if (label != null) - Positioned( - top: 78, - child: AnimatedBuilder( - animation: _labelCtrl, - builder: (context, _) => Opacity( - opacity: _labelOpacity.value, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - label, - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ), - ), ], ), ), @@ -1008,6 +987,71 @@ class _HeartBurstState extends State<_HeartBurst> } } +// ─── Heart burst ───────────────────────────────────────────────────────────── + +/// Heart + Lottie explosion lifted from getlantern/unbounded. The pink +/// heart is the inline SVG path from `notification/explosion.tsx` +/// (FF5A79 fill, 32×27 viewBox); the burst is `explosion.json` played +/// once via the `lottie` Flutter package. Rendered inside _ArrivalCard +/// (under the globe), NOT anchored to globe coords — anchoring forced +/// a repaint per globe rotation frame and made rotation jittery. +class _HeartBurst extends StatefulWidget { + const _HeartBurst(); + + @override + State<_HeartBurst> createState() => _HeartBurstState(); +} + +class _HeartBurstState extends State<_HeartBurst> + with TickerProviderStateMixin { + AnimationController? _lottieCtrl; + + @override + void dispose() { + _lottieCtrl?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + // Lottie explosion sized so particle spray extends slightly + // past the card bounds (Clip.none on parent lets it overflow). + // Mirrors unbounded's LottieWrapper sizing, scaled down for an + // inline card slot. + Positioned( + width: 110, + height: 110, + child: Lottie.asset( + 'assets/unbounded/explosion.json', + repeat: false, + fit: BoxFit.contain, + onLoaded: (composition) { + _lottieCtrl = AnimationController( + vsync: this, + duration: composition.duration, + )..forward(); + setState(() {}); + }, + controller: _lottieCtrl, + ), + ), + // Heart SVG path — exact coords from unbounded's inline SVG. + const SizedBox( + width: 22, + height: 19, + child: CustomPaint(painter: _HeartPainter()), + ), + ], + ), + ); + } +} + /// Pink heart from `getlantern/unbounded` — exact SVG path coords /// (viewBox 0 0 32 27, fill #FF5A79). class _HeartPainter extends CustomPainter { From 3dda05181568af5771505217388ca3464ad4f2e6 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 12:07:10 -0600 Subject: [PATCH 22/35] =?UTF-8?q?unbounded:=20phase=201=20=E2=80=94=20tab?= =?UTF-8?q?=20shell=20+=20Unbounded=20as=20a=20top-level=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures Home into a two-tab shell (VPN + Unbounded) per the Figma spec at figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287 and tracking ticket getlantern/engineering#3455. Previously the peer-share UI sat behind a "Share My Connection" entry on the VPN settings screen that opened it as a modal; the spec elevates it to a peer of the VPN view. - New lib/features/home/vpn_tab.dart: VpnTab body lifted from the old Home (toggle, data usage, location, routing, split tunneling). Scaffold/AppBar moved up to the shell. - home.dart: Home becomes the tab shell. AppBar hosts the Lantern logo, settings menu, account/sign-in actions, plus a TabBar with green/grey-dot tab labels (green when feature enabled per spec). Onboarding, macOS sysext, and telemetry-consent init preserved inside the shell so launch behaviour is unchanged. - share_my_connection.dart: ShareMyConnectionScreen renamed to UnboundedTab, BaseScreen wrapper dropped (shell provides chrome). Description text updated to the spec's "Help others bypass censorship by securely sharing your connection." - Arrival toast copy updated to match the spec: "Helping a new person in " while a peer is arriving, "Waiting for connections..." in the idle state (new _WaitingCard). - vpn_setting.dart: SmC modal entry removed — there is no longer a Share-My-Connection tile here. Unused peerProxy watch dropped. Followups (separate phases): Unbounded Settings sheet (Auto-enable + Hide Unbounded toggles), auto-enable on VPN connect, first-visit Welcome popup. Files/class names still say "share_my_connection" and "ShareNotifier" to keep this diff focused; rename to "unbounded" is a polish step at the end. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/features/home/home.dart | 382 +++++++----------- lib/features/home/vpn_tab.dart | 136 +++++++ lib/features/setting/vpn_setting.dart | 39 +- .../share_my_connection.dart | 80 ++-- 4 files changed, 336 insertions(+), 301 deletions(-) create mode 100644 lib/features/home/vpn_tab.dart diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 961deb9022..04136d2ab1 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -1,96 +1,87 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lantern/core/common/app_text_styles.dart'; import 'package:lantern/core/extensions/user_data.dart'; import 'package:lantern/core/models/feature_flags.dart'; import 'package:lantern/core/utils/pro_utils.dart'; -import 'package:lantern/core/widgets/info_row.dart'; -import 'package:lantern/core/widgets/setting_tile.dart'; import 'package:lantern/features/home/provider/app_event_notifier.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/features/home/provider/feature_flag_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; -import 'package:lantern/features/vpn/location_setting.dart'; +import 'package:lantern/features/home/vpn_tab.dart'; +import 'package:lantern/features/share_my_connection/share_my_connection.dart'; import 'package:lantern/features/vpn/provider/available_servers_notifier.dart'; -import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; -import 'package:lantern/features/vpn/vpn_status.dart'; -import 'package:lantern/features/vpn/vpn_switch.dart'; +import 'package:lantern/features/vpn/provider/vpn_notifier.dart'; import '../../core/common/common.dart'; -enum _SettingTileType { smartLocation, splitTunneling, smartRouting } - +/// Root tab shell hosting the VPN and Unbounded tabs. Tab strip lives in +/// the AppBar so the chrome (Lantern logo + settings menu + account +/// actions) is shared across tabs and lines up with the Figma spec at +/// figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287. +/// +/// Tab labels carry a small dot indicator that turns green when the +/// matching feature is active (VPN: connected; Unbounded: peer share +/// on) and grey otherwise — also per spec. @RoutePage(name: 'Home') -class Home extends StatefulHookConsumerWidget { +class Home extends HookConsumerWidget { const Home({super.key}); @override - ConsumerState createState() => _HomeState(); -} - -class _HomeState extends ConsumerState { - TextTheme? textTheme; - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - /// Kick off the server fetch as soon as Home mounts so the Smart - /// Location tile can reflect the fastest server without waiting for - /// the user to open the server-selection screen. - ref.read(availableServersProvider); - - final appSetting = ref.read(appSettingProvider); - final appSettingNotifier = ref.read(appSettingProvider.notifier); - if (!appSetting.onboardingCompleted) { - appLogger.info( - "User has not completed onboarding, navigating to Onboarding Screen", - ); - appRouter.push(const Onboarding()); - return; - } - - if (PlatformUtils.isMacOS) { - /// Show macOS system extension dialog if needed - appLogger.info( - "App Setting - showSplashScreen: ${appSetting.showSplashScreen}", - ); - if (appSetting.showSplashScreen) { - appLogger.info("Showing System Extension Dialog"); - appRouter.push(const MacOSExtensionDialog()); - //User has seen dialog, do not show again - appLogger.info("Setting showSplashScreen to false"); - appSettingNotifier.setSplashScreen(false); - return; - } - } - }); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final tabController = useTabController(initialLength: 2); final isUserPro = ref.watch(isUserProProvider); - final featureFlag = ref.watch(featureFlagProvider); final userLoggedIn = ref.watch( appSettingProvider.select((s) => s.userLoggedIn), ); + final featureFlag = ref.watch(featureFlagProvider); + final vpnStatus = ref.watch(vpnProvider); + final shareActive = ref.watch(shareProvider.select((s) => s.active)); + + // First-frame side effects: kick off server fetch, gate onboarding, + // macOS sysext dialog. Lifted unchanged from the old Home body so + // app-launch behaviour stays the same after the tab refactor. + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(availableServersProvider); + final appSetting = ref.read(appSettingProvider); + final appSettingNotifier = ref.read(appSettingProvider.notifier); + if (!appSetting.onboardingCompleted) { + appLogger.info( + "User has not completed onboarding, navigating to Onboarding Screen", + ); + appRouter.push(const Onboarding()); + return; + } + if (PlatformUtils.isMacOS) { + appLogger.info( + "App Setting - showSplashScreen: ${appSetting.showSplashScreen}", + ); + if (appSetting.showSplashScreen) { + appLogger.info("Showing System Extension Dialog"); + appRouter.push(const MacOSExtensionDialog()); + appLogger.info("Setting showSplashScreen to false"); + appSettingNotifier.setSplashScreen(false); + } + } + }); + return null; + }, const []); + + // Telemetry consent dialog — fires once per app session after the + // first successful connection, gated on the metrics + traces + // feature flags. Preserved from the old Home behaviour. useEffect(() { final appSetting = ref.read(appSettingProvider); if (appSetting.successfulConnection) { - appLogger.info( - "User has successfully connected, checking if need to show Help Lantern Dialog or not", - ); if (!appSetting.telemetryDialogDismissed && (featureFlag.getBool(FeatureFlag.metrics) && featureFlag.getBool(FeatureFlag.traces))) { - appLogger.info("Showing Help Lantern Dialog"); WidgetsBinding.instance.addPostFrameCallback((_) { - showHelpLanternDialog(); + _showHelpLanternDialog(context, ref); ref.read(appSettingProvider.notifier).setShowTelemetryDialog(true); }); } @@ -98,21 +89,15 @@ class _HomeState extends ConsumerState { return null; }, [featureFlag]); - textTheme = Theme.of(context).textTheme; ref.read(appEventProvider); + return Scaffold( key: const Key('home.screen'), appBar: AppBar( title: LanternLogo(isPro: isUserPro, color: context.textPrimary), - bottom: PreferredSize( - preferredSize: Size.fromHeight(0), - child: DividerSpace(padding: EdgeInsets.zero), - ), elevation: 5, leading: IconButton( - onPressed: () { - appRouter.push(Setting()); - }, + onPressed: () => appRouter.push(Setting()), icon: const AppImage(path: AppImagePaths.menu), ), actions: [ @@ -125,209 +110,120 @@ class _HomeState extends ConsumerState { final email = localUser!.legacyUserData.email; final isPro = localUser.legacyUserData.isPro; if (isPro && !userSignedIn) { - // this means user has pro account but not signed in await showProAccountFlowDialog( context: context, hasEmail: email.isNotEmpty, ); return; } - appRouter.push(Account()); }, ) else if (!userLoggedIn) AppTextButton( label: 'sign_in'.i18n, - onPressed: () { - appRouter.push(const SignInEmail()); - }, + onPressed: () => appRouter.push(const SignInEmail()), ), ], + bottom: TabBar( + controller: tabController, + tabs: [ + _TabLabel(label: 'VPN', active: vpnStatus == VPNStatus.connected), + _TabLabel(label: 'Unbounded', active: shareActive), + ], + ), ), - body: SafeArea(child: _buildBody(ref, isUserPro)), - ); - } - - Widget _buildBody(WidgetRef ref, bool isUserPro) { - final serverLocation = ref.watch(serverLocationProvider); - - final serverType = serverLocation.serverType.toServerLocationType; - - return Padding( - padding: EdgeInsets.symmetric(horizontal: defaultSize), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (isUserPro) SizedBox(height: 0) else ProBanner(), - VPNSwitch(), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isUserPro) ...{ - if (serverType == ServerLocationType.privateServer) - InfoRow(text: 'private_server_usage_message'.i18n) - else if (PlatformUtils.isIOS) - const SizedBox.shrink() - else - const DataUsage(), - }, - SizedBox(height: 8), - _buildSetting(ref), - SizedBox(height: 10.h), - ], - ), + body: TabBarView( + controller: tabController, + children: const [ + VpnTab(), + UnboundedTab(), ], ), ); } +} - Widget _buildSetting(WidgetRef ref) { - final routingMode = ref.watch( - radianceSettingsProvider.select((s) => s.routingMode), - ); - final isSplitTunnelingOn = ref.watch( - radianceSettingsProvider.select((s) => s.splitTunneling), - ); +/// Tab label with the green/grey status dot from the Figma spec. +class _TabLabel extends StatelessWidget { + const _TabLabel({required this.label, required this.active}); - return Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: AppColors.shadowColor, - blurRadius: 32, - offset: Offset(0, 4), - spreadRadius: 0, + final String label; + final bool active; + + @override + Widget build(BuildContext context) { + return Tab( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active ? AppColors.green4 : context.textDisabled, + ), ), + const SizedBox(width: 6), + Text(label), ], ), - child: Card( - elevation: 0, - margin: EdgeInsets.zero, - child: Column( - children: [ - VpnStatus(), - DividerSpace(), - LocationSetting(), - if (!PlatformUtils.isIOS) ...{ - DividerSpace(), - SettingTile( - label: 'routing_mode'.i18n, - icon: AppImagePaths.route, - value: routingMode.label(), - actions: [ - IconButton( - onPressed: null, - style: ElevatedButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - icon: AppImage(path: AppImagePaths.arrowForward), - padding: EdgeInsets.zero, - constraints: BoxConstraints(), - visualDensity: VisualDensity.compact, - ), - ], - onTap: () => onSettingTileTap(_SettingTileType.smartRouting), - ), - }, - if (PlatformUtils.isAndroid || - PlatformUtils.isMacOS || - PlatformUtils.isWindows) ...{ - DividerSpace(), - SettingTile( - label: 'split_tunneling'.i18n, - icon: AppImagePaths.callSpilt, - value: isSplitTunnelingOn ? 'enabled'.i18n : 'disabled'.i18n, - actions: [ - IconButton( - onPressed: null, - style: ElevatedButton.styleFrom( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - icon: AppImage(path: AppImagePaths.arrowForward), - padding: EdgeInsets.zero, - constraints: BoxConstraints(), - visualDensity: VisualDensity.compact, - ), - ], - onTap: () => onSettingTileTap(_SettingTileType.splitTunneling), - ), - }, - ], - ), - ), ); } +} - void onSettingTileTap(_SettingTileType tileType) { - switch (tileType) { - case _SettingTileType.smartLocation: - appRouter.push(const ServerSelection()); - break; - case _SettingTileType.splitTunneling: - appRouter.push(const SplitTunneling()); - break; - case _SettingTileType.smartRouting: - appRouter.push(const SmartRouting()); - } - } - - void showHelpLanternDialog() { - AppDialog.customDialog( - context: context, - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: 24), - AppImage(path: AppImagePaths.assessment), - SizedBox(height: 24), - Text( - 'help_improve_lantern'.i18n, - style: textTheme!.headlineSmall!.copyWith( - color: context.textPrimary, - ), - ), - SizedBox(height: defaultSize), - Text( - 'share_anonymous_usage_data'.i18n, - style: textTheme!.bodyMedium!.copyWith( - color: context.textSecondary, - ), - ), - SizedBox(height: defaultSize), - Text( - 'data_we_collect'.i18n, - style: AppTextStyles.bodyMediumBold.copyWith( - color: context.textSecondary, - ), - ), - SizedBox(height: defaultSize), - Text( - 'you_can_change_anytime'.i18n, - style: textTheme!.bodyMedium!.copyWith( - color: context.textSecondary, - ), +void _showHelpLanternDialog(BuildContext context, WidgetRef ref) { + final textTheme = Theme.of(context).textTheme; + AppDialog.customDialog( + context: context, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 24), + const AppImage(path: AppImagePaths.assessment), + const SizedBox(height: 24), + Text( + 'help_improve_lantern'.i18n, + style: textTheme.headlineSmall!.copyWith(color: context.textPrimary), + ), + SizedBox(height: defaultSize), + Text( + 'share_anonymous_usage_data'.i18n, + style: textTheme.bodyMedium!.copyWith(color: context.textSecondary), + ), + SizedBox(height: defaultSize), + Text( + 'data_we_collect'.i18n, + style: AppTextStyles.bodyMediumBold.copyWith( + color: context.textSecondary, ), - ], - ), - action: [ - AppTextButton( - label: 'dont_allow'.i18n, - textColor: context.textDisabled, - onPressed: () { - context.pop(); - ref.read(radianceSettingsProvider.notifier).setTelemetry(false); - }, ), - AppTextButton( - label: 'allow'.i18n, - textColor: AppColors.blue6, - onPressed: () { - context.pop(); - ref.read(radianceSettingsProvider.notifier).setTelemetry(true); - }, + SizedBox(height: defaultSize), + Text( + 'you_can_change_anytime'.i18n, + style: textTheme.bodyMedium!.copyWith(color: context.textSecondary), ), ], - ); - } + ), + action: [ + AppTextButton( + label: 'dont_allow'.i18n, + textColor: context.textDisabled, + onPressed: () { + context.pop(); + ref.read(radianceSettingsProvider.notifier).setTelemetry(false); + }, + ), + AppTextButton( + label: 'allow'.i18n, + textColor: AppColors.blue6, + onPressed: () { + context.pop(); + ref.read(radianceSettingsProvider.notifier).setTelemetry(true); + }, + ), + ], + ); } diff --git a/lib/features/home/vpn_tab.dart b/lib/features/home/vpn_tab.dart new file mode 100644 index 0000000000..68302d04e1 --- /dev/null +++ b/lib/features/home/vpn_tab.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/widgets/info_row.dart'; +import 'package:lantern/core/widgets/setting_tile.dart'; +import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; +import 'package:lantern/features/vpn/location_setting.dart'; +import 'package:lantern/features/vpn/provider/server_location_notifier.dart'; +import 'package:lantern/features/vpn/vpn_status.dart'; +import 'package:lantern/features/vpn/vpn_switch.dart'; + +import '../../core/common/common.dart'; + +/// VPN tab body — the connect toggle, data usage, location, routing and +/// split-tunnel rows. Originally the body of the Home screen; lifted out +/// when Home was refactored into a two-tab shell (VPN + Unbounded). No +/// Scaffold or AppBar — the shell provides that chrome. +class VpnTab extends ConsumerWidget { + const VpnTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isUserPro = ref.watch(isUserProProvider); + final serverLocation = ref.watch(serverLocationProvider); + final serverType = serverLocation.serverType.toServerLocationType; + + return SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: defaultSize), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (isUserPro) const SizedBox(height: 0) else const ProBanner(), + const VPNSwitch(), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isUserPro) ...{ + if (serverType == ServerLocationType.privateServer) + InfoRow(text: 'private_server_usage_message'.i18n) + else if (PlatformUtils.isIOS) + const SizedBox.shrink() + else + const DataUsage(), + }, + const SizedBox(height: 8), + _SettingCard(), + SizedBox(height: 10.h), + ], + ), + ], + ), + ), + ); + } +} + +class _SettingCard extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final routingMode = ref.watch( + radianceSettingsProvider.select((s) => s.routingMode), + ); + final isSplitTunnelingOn = ref.watch( + radianceSettingsProvider.select((s) => s.splitTunneling), + ); + + return Container( + decoration: const BoxDecoration( + boxShadow: [ + BoxShadow( + color: AppColors.shadowColor, + blurRadius: 32, + offset: Offset(0, 4), + spreadRadius: 0, + ), + ], + ), + child: Card( + elevation: 0, + margin: EdgeInsets.zero, + child: Column( + children: [ + const VpnStatus(), + const DividerSpace(), + const LocationSetting(), + if (!PlatformUtils.isIOS) ...{ + const DividerSpace(), + SettingTile( + label: 'routing_mode'.i18n, + icon: AppImagePaths.route, + value: routingMode.label(), + actions: [ + IconButton( + onPressed: null, + style: ElevatedButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const AppImage(path: AppImagePaths.arrowForward), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + ], + onTap: () => appRouter.push(const SmartRouting()), + ), + }, + if (PlatformUtils.isAndroid || + PlatformUtils.isMacOS || + PlatformUtils.isWindows) ...{ + const DividerSpace(), + SettingTile( + label: 'split_tunneling'.i18n, + icon: AppImagePaths.callSpilt, + value: isSplitTunnelingOn ? 'enabled'.i18n : 'disabled'.i18n, + actions: [ + IconButton( + onPressed: null, + style: ElevatedButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const AppImage(path: AppImagePaths.arrowForward), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + visualDensity: VisualDensity.compact, + ), + ], + onTap: () => appRouter.push(const SplitTunneling()), + ), + }, + ], + ), + ), + ); + } +} diff --git a/lib/features/setting/vpn_setting.dart b/lib/features/setting/vpn_setting.dart index cfcac3afd7..07cc0cf7e0 100644 --- a/lib/features/setting/vpn_setting.dart +++ b/lib/features/setting/vpn_setting.dart @@ -6,7 +6,6 @@ import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/widgets/split_tunneling_tile.dart'; import 'package:lantern/core/widgets/switch_button.dart'; import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; -import 'package:lantern/features/share_my_connection/share_my_connection.dart'; @RoutePage(name: 'VPNSetting') class VPNSetting extends HookConsumerWidget { @@ -36,10 +35,6 @@ class VPNSetting extends HookConsumerWidget { final telemetryConsent = ref.watch( radianceSettingsProvider.select((s) => s.telemetry), ); - final peerProxy = ref.watch( - radianceSettingsProvider.select((s) => s.peerProxy), - ); - return ListView( padding: const EdgeInsets.all(0), shrinkWrap: true, @@ -121,36 +116,10 @@ class VPNSetting extends HookConsumerWidget { }, ), ), - if (PlatformUtils.isDesktop) ...{ - SizedBox(height: 16), - AppCard( - padding: EdgeInsets.zero, - child: AppTile( - label: 'share_my_connection'.i18n, - subtitle: Text( - peerProxy - ? 'On — tap to view' - : 'share_my_connection_subtitle'.i18n, - style: textTheme.labelMedium!.copyWith( - color: context.textTertiary, - letterSpacing: 0.0, - ), - ), - icon: AppImagePaths.share, - trailing: AppImage( - path: AppImagePaths.arrowForward, - height: 20, - ), - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ShareMyConnectionScreen(), - ), - ); - }, - ), - ), - }, + // The "Share My Connection" entry that used to push a SmC + // screen from here moved to a top-level Unbounded tab in the + // Home shell — see lib/features/home/home.dart. Toggling peer + // share now happens inside that tab. SizedBox(height: 16), AppCard( padding: EdgeInsets.zero, diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index a4f3320274..f22df75333 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -463,10 +463,14 @@ class ShareNotifier extends Notifier { final shareProvider = NotifierProvider(ShareNotifier.new); -// ─── Screen ────────────────────────────────────────────────────────────────── +// ─── Tab body ──────────────────────────────────────────────────────────────── -class ShareMyConnectionScreen extends HookConsumerWidget { - const ShareMyConnectionScreen({super.key}); +/// Unbounded tab content, rendered inside the Home tab shell (see +/// home.dart). Hosts the description text, globe + arrival toast, the +/// status card with the toggle, and the advanced section. No Scaffold +/// or AppBar — the shell provides the chrome and the tab strip. +class UnboundedTab extends HookConsumerWidget { + const UnboundedTab({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -474,17 +478,15 @@ class ShareMyConnectionScreen extends HookConsumerWidget { final notifier = ref.read(shareProvider.notifier); final textTheme = Theme.of(context).textTheme; - return BaseScreen( - title: 'Share My Connection', - body: Padding( + return SafeArea( + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ const SizedBox(height: 12), Text( - 'Help others bypass censorship by sharing a small portion of ' - 'your home internet connection. While sharing is on, traffic ' - 'from users in censored regions will egress through your IP.', + 'Help others bypass censorship by securely sharing your ' + 'connection.', style: textTheme.bodyMedium, ), const SizedBox(height: 16), @@ -493,11 +495,11 @@ class ShareMyConnectionScreen extends HookConsumerWidget { child: Stack( children: [ Positioned.fill(child: _GlobeView()), - // Floating "new connection from X" toast — overlays the - // bottom of the globe area rather than the peer's exact - // location on the sphere. Anchoring to projected coords - // forced the burst to repaint every globe rotation - // frame, which made the rotation jittery. + // Floating arrival toast — overlays the bottom of the + // globe area rather than the peer's exact location on + // the sphere. Anchoring to projected coords forced the + // burst to repaint every globe rotation frame, which + // made the rotation jittery. const Positioned( left: 0, right: 0, @@ -865,12 +867,12 @@ class _GlobeViewState extends ConsumerState<_GlobeView> { // ─── Arrival toast ─────────────────────────────────────────────────────────── -/// Floating notification overlay shown under the globe when a new peer -/// arrives. Mirrors the unbounded.lantern.io notification pattern: -/// heart-burst on the left, `New connection from ` text on -/// the right. Slides up + fades in, auto-hides after ~3.5s. Listens -/// directly to ShareNotifier.connectionEvents so we don't depend on -/// the globe widget for triggering. +/// Floating notification overlay shown under the globe. Mirrors the +/// unbounded.lantern.io notification pattern: heart-burst on the left, +/// `Helping a new person in ` text on the right while a peer +/// is connecting. When no peer has arrived recently, falls back to +/// `Waiting for connections...` (no heart) per the Figma spec. Slides +/// up + fades in, auto-hides connection arrivals after ~3.5s. class _ArrivalToast extends ConsumerStatefulWidget { const _ArrivalToast(); @@ -926,7 +928,10 @@ class _ArrivalToastState extends ConsumerState<_ArrivalToast> { ), ), child: event == null - ? const SizedBox.shrink(key: ValueKey('arrival-hidden')) + // Idle state — the spec wants a "Waiting for connections..." + // pill rather than empty space, so the user knows the screen + // is live and just nothing has arrived yet. + ? const _WaitingCard(key: ValueKey('arrival-waiting')) : _ArrivalCard( // ValueKey forces AnimatedSwitcher to swap children when a // new arrival lands while the previous toast is still up, @@ -973,8 +978,8 @@ class _ArrivalCard extends StatelessWidget { const SizedBox(width: 12), Text( flagEmoji.isEmpty - ? 'New connection from $countryName' - : '$flagEmoji New connection from $countryName', + ? 'Helping a new person in $countryName' + : '$flagEmoji Helping a new person in $countryName', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, @@ -987,6 +992,35 @@ class _ArrivalCard extends StatelessWidget { } } +/// Idle-state companion to _ArrivalCard. Same pill chrome, no heart, +/// `Waiting for connections...` text. Shown whenever the toast switch +/// has no current arrival to display. +class _WaitingCard extends StatelessWidget { + const _WaitingCard({super.key}); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(100), + border: Border.all(color: Colors.black12), + ), + child: Text( + 'Waiting for connections...', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).hintColor, + ), + ), + ), + ); + } +} + // ─── Heart burst ───────────────────────────────────────────────────────────── /// Heart + Lottie explosion lifted from getlantern/unbounded. The pink From 33886b4b23a6bc7e5d11726cf60e55f86ddba7a3 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 12:12:38 -0600 Subject: [PATCH 23/35] =?UTF-8?q?unbounded:=20phase=202=20=E2=80=94=20Unbo?= =?UTF-8?q?unded=20Settings=20sheet=20+=20hide-tab=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Unbounded Settings sheet from the Figma spec (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287), reached from the main Settings menu (between VPN Settings and Language). Two toggles: - Auto-enable Unbounded — defaults on, subtitle "Turn on automatically when Lantern is open". The actual auto-enable wiring (listening to vpnProvider and toggling peer-proxy) lands in phase 3. - Hide Unbounded — defaults off, subtitle "Removes Unbounded from the top of this screen". When on, the Home shell hides the Unbounded tab AND collapses the tab strip entirely (single-tab case), falling back to rendering VpnTab directly. State persistence via AppSetting: - unboundedAutoEnable (default true) - unboundedHidden (default false) - unboundedWelcomeSeen (default false) — added now, used in phase 4 All three round-trip via toJson/fromJson and the new setUnboundedAutoEnable / setUnboundedHidden / setUnboundedWelcomeSeen notifier methods. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/core/models/app_setting.dart | 25 ++++++ lib/features/home/home.dart | 40 +++++---- .../home/provider/app_setting_notifier.dart | 9 ++ lib/features/setting/setting.dart | 14 ++++ lib/features/setting/unbounded_setting.dart | 82 +++++++++++++++++++ 5 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 lib/features/setting/unbounded_setting.dart diff --git a/lib/core/models/app_setting.dart b/lib/core/models/app_setting.dart index 3f6eb7a6ee..a86bb1ccc0 100644 --- a/lib/core/models/app_setting.dart +++ b/lib/core/models/app_setting.dart @@ -8,6 +8,14 @@ class AppSetting { final bool successfulConnection; final String dataCapThreshold; final bool onboardingCompleted; + // Unbounded preferences. autoEnable: turn the peer share on whenever + // the VPN connects (defaults on per the Figma spec). hideTab: hide + // the Unbounded tab + collapse the tab bar when the user doesn't + // want to see it. welcomeSeen: tracks the first-visit info popup so + // we only show it once. All persisted across launches. + final bool unboundedAutoEnable; + final bool unboundedHidden; + final bool unboundedWelcomeSeen; const AppSetting({ this.themeMode = 'system', @@ -19,6 +27,9 @@ class AppSetting { this.successfulConnection = false, this.dataCapThreshold = '', this.onboardingCompleted = false, + this.unboundedAutoEnable = true, + this.unboundedHidden = false, + this.unboundedWelcomeSeen = false, }); AppSetting copyWith({ @@ -31,6 +42,9 @@ class AppSetting { bool? successfulConnection, String? dataCapThreshold, bool? onboardingCompleted, + bool? unboundedAutoEnable, + bool? unboundedHidden, + bool? unboundedWelcomeSeen, }) { return AppSetting( locale: newLocale ?? locale, @@ -42,6 +56,9 @@ class AppSetting { successfulConnection: successfulConnection ?? this.successfulConnection, dataCapThreshold: dataCapThreshold ?? this.dataCapThreshold, onboardingCompleted: onboardingCompleted ?? this.onboardingCompleted, + unboundedAutoEnable: unboundedAutoEnable ?? this.unboundedAutoEnable, + unboundedHidden: unboundedHidden ?? this.unboundedHidden, + unboundedWelcomeSeen: unboundedWelcomeSeen ?? this.unboundedWelcomeSeen, ); } @@ -55,6 +72,9 @@ class AppSetting { 'successfulConnection': successfulConnection, 'dataCapThreshold': dataCapThreshold, 'onboardingCompleted': onboardingCompleted, + 'unboundedAutoEnable': unboundedAutoEnable, + 'unboundedHidden': unboundedHidden, + 'unboundedWelcomeSeen': unboundedWelcomeSeen, }; factory AppSetting.fromJson(Map json) => AppSetting( @@ -67,5 +87,10 @@ class AppSetting { successfulConnection: json['successfulConnection'] == true, dataCapThreshold: (json['dataCapThreshold'] ?? '').toString(), onboardingCompleted: json['onboardingCompleted'] == true, + // Default to true when missing (first-time post-upgrade users + // should get the auto-enable behaviour the spec calls for). + unboundedAutoEnable: json['unboundedAutoEnable'] != false, + unboundedHidden: json['unboundedHidden'] == true, + unboundedWelcomeSeen: json['unboundedWelcomeSeen'] == true, ); } diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 04136d2ab1..1aa8b026ef 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -37,6 +37,9 @@ class Home extends HookConsumerWidget { final userLoggedIn = ref.watch( appSettingProvider.select((s) => s.userLoggedIn), ); + final unboundedHidden = ref.watch( + appSettingProvider.select((s) => s.unboundedHidden), + ); final featureFlag = ref.watch(featureFlagProvider); final vpnStatus = ref.watch(vpnProvider); final shareActive = ref.watch(shareProvider.select((s) => s.active)); @@ -125,21 +128,30 @@ class Home extends HookConsumerWidget { onPressed: () => appRouter.push(const SignInEmail()), ), ], - bottom: TabBar( - controller: tabController, - tabs: [ - _TabLabel(label: 'VPN', active: vpnStatus == VPNStatus.connected), - _TabLabel(label: 'Unbounded', active: shareActive), - ], - ), - ), - body: TabBarView( - controller: tabController, - children: const [ - VpnTab(), - UnboundedTab(), - ], + // Tab strip collapses when the user has hidden the Unbounded + // tab in Unbounded Settings — with only one tab left, a strip + // is just noise. Body falls back to VpnTab directly. + bottom: unboundedHidden + ? null + : TabBar( + controller: tabController, + tabs: [ + _TabLabel( + label: 'VPN', + active: vpnStatus == VPNStatus.connected), + _TabLabel(label: 'Unbounded', active: shareActive), + ], + ), ), + body: unboundedHidden + ? const VpnTab() + : TabBarView( + controller: tabController, + children: const [ + VpnTab(), + UnboundedTab(), + ], + ), ); } } diff --git a/lib/features/home/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index 97af2d8365..4738642a36 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -105,6 +105,15 @@ class AppSettingNotifier extends _$AppSettingNotifier { if (value) unawaited(_writeInitMarker()); } + void setUnboundedAutoEnable(bool value) => + update(state.copyWith(unboundedAutoEnable: value)); + + void setUnboundedHidden(bool value) => + update(state.copyWith(unboundedHidden: value)); + + void setUnboundedWelcomeSeen(bool value) => + update(state.copyWith(unboundedWelcomeSeen: value)); + Future _writeInitMarker() async { try { final dataDir = await AppStorageUtils.getAppDirectory(); diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index 027eb40279..eb507518aa 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -12,6 +12,7 @@ import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/plans/restore_purchase_mixin.dart'; import 'package:lantern/features/setting/appearance.dart' show appearanceModeLabel, showAppearanceBottomSheet; +import 'package:lantern/features/setting/unbounded_setting.dart'; import '../../core/services/injection_container.dart'; @@ -19,6 +20,7 @@ enum _SettingType { account, signIn, vpnSetting, + unboundedSetting, language, appearance, support, @@ -143,6 +145,13 @@ class _SettingState extends ConsumerState onPressed: () => settingMenuTap(_SettingType.vpnSetting), ), DividerSpace(), + AppTile( + label: 'Unbounded Settings', + icon: AppImagePaths.share, + onPressed: () => + settingMenuTap(_SettingType.unboundedSetting), + ), + DividerSpace(), AppTile( label: 'language'.i18n, icon: AppImagePaths.translate, @@ -317,6 +326,11 @@ class _SettingState extends ConsumerState case _SettingType.vpnSetting: appRouter.push(VPNSetting()); break; + case _SettingType.unboundedSetting: + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UnboundedSetting()), + ); + break; case _SettingType.browserUnbounded: // TODO: Handle this case. throw UnimplementedError(); diff --git a/lib/features/setting/unbounded_setting.dart b/lib/features/setting/unbounded_setting.dart new file mode 100644 index 0000000000..559b85d8e3 --- /dev/null +++ b/lib/features/setting/unbounded_setting.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lantern/core/widgets/switch_button.dart'; +import 'package:lantern/features/home/provider/app_setting_notifier.dart'; + +import '../../core/common/common.dart'; + +/// Unbounded Settings sheet, reached from the main Settings menu. Two +/// toggles per the Figma spec +/// (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287): +/// +/// 1. Auto-enable Unbounded — turn Unbounded on automatically when +/// Lantern (VPN) is connected. The actual auto-enable wiring lives +/// in the Home shell (or a VPN-status listener) and reads this flag. +/// 2. Hide Unbounded — collapse the Unbounded tab in the Home shell +/// when the user doesn't want to see it. With only the VPN tab +/// left, Home hides the tab strip entirely. +class UnboundedSetting extends ConsumerWidget { + const UnboundedSetting({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final autoEnable = ref.watch( + appSettingProvider.select((s) => s.unboundedAutoEnable), + ); + final hidden = ref.watch( + appSettingProvider.select((s) => s.unboundedHidden), + ); + final notifier = ref.read(appSettingProvider.notifier); + final textTheme = Theme.of(context).textTheme; + + return BaseScreen( + title: 'Unbounded Settings', + body: ListView( + children: [ + const SizedBox(height: 8), + AppCard( + padding: EdgeInsets.zero, + child: Column( + children: [ + AppTile( + label: 'Auto-enable Unbounded', + subtitle: Text( + 'Turn on automatically when Lantern is open', + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + letterSpacing: 0.0, + ), + ), + icon: AppImagePaths.share, + trailing: SwitchButton( + value: autoEnable, + onChanged: notifier.setUnboundedAutoEnable, + ), + onPressed: () => + notifier.setUnboundedAutoEnable(!autoEnable), + ), + DividerSpace(), + AppTile( + label: 'Hide Unbounded', + subtitle: Text( + 'Removes Unbounded from the top of this screen', + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + letterSpacing: 0.0, + ), + ), + icon: const Icon(Icons.visibility_off_outlined), + trailing: SwitchButton( + value: hidden, + onChanged: notifier.setUnboundedHidden, + ), + onPressed: () => notifier.setUnboundedHidden(!hidden), + ), + ], + ), + ), + ], + ), + ); + } +} From ea55b911b7c52e5bb387685b3d456668521cedb5 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 12:14:15 -0600 Subject: [PATCH 24/35] =?UTF-8?q?unbounded:=20phase=203=20=E2=80=94=20auto?= =?UTF-8?q?-enable=20on=20VPN=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the "Auto-enable Unbounded" toggle in Unbounded Settings is on (default per phase 2), Unbounded turns on automatically the moment the VPN reaches the connected state — per the Figma spec and ticket getlantern/engineering#3455 ("turns on automatically when Lantern connects"). - New ShareNotifier.autoStart(): public, programmatic entry point that mirrors the toggle() probe-then-start path but skips the disclosure dialog because the user has already opted in via settings. No-ops if already active or probing. - Home shell uses ref.listen(vpnProvider, ...) to detect the disconnected → connected transition. On match, reads the auto-enable flag and current share state, then calls autoStart in a microtask so we don't mutate provider state from inside the listen callback. Disconnect path is left alone — turning Unbounded off when the VPN drops would be surprising; the user can toggle it off manually. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/features/home/home.dart | 19 +++++++++++++++ .../share_my_connection.dart | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 1aa8b026ef..7e8fbb9ff4 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -94,6 +94,25 @@ class Home extends HookConsumerWidget { ref.read(appEventProvider); + // Auto-enable Unbounded on VPN-connected transitions, gated on the + // "Auto-enable Unbounded" toggle from Unbounded Settings. The actual + // start (UPnP probe + radiance peer_share_enabled setting) happens + // inside ShareNotifier.autoStart; the user has already consented in + // settings, so we skip the disclosure dialog. + ref.listen(vpnProvider, (prev, next) { + if (prev == next) return; + if (next != VPNStatus.connected) return; + final autoEnable = + ref.read(appSettingProvider).unboundedAutoEnable; + if (!autoEnable) return; + final share = ref.read(shareProvider); + if (share.active || share.probing) return; + // Defer to avoid mutating provider state inside the listen callback. + Future.microtask( + () => ref.read(shareProvider.notifier).autoStart(ref), + ); + }); + return Scaffold( key: const Key('home.screen'), appBar: AppBar( diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index f22df75333..48a93b53f1 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -231,6 +231,30 @@ class ShareNotifier extends Notifier { } } + /// Programmatic entry point used by the Home shell's auto-enable + /// listener (VPN-connected → Unbounded on). Mirrors `toggle()` but + /// skips the disclosure dialog because the user has already opted + /// in via the Unbounded Settings sheet. No-ops if already active or + /// in flight. + Future autoStart(WidgetRef widgetRef) async { + if (state.active || state.probing) return; + state = state.copyWith(probing: true); + final manualPortRes = + await widgetRef.read(lanternServiceProvider).getPeerManualPort(); + final manualPort = manualPortRes.fold((_) => 0, (p) => p); + if (manualPort > 0) { + await _start(widgetRef, ShareMode.smc); + return; + } + // MOCK UPnP probe — same as toggle(), pending real FFI. + await Future.delayed(const Duration(milliseconds: 1500)); + final upnpAvailable = Random().nextBool(); + await _start( + widgetRef, + upnpAvailable ? ShareMode.smc : ShareMode.unbounded, + ); + } + Future _start(WidgetRef widgetRef, ShareMode mode) async { state = ShareState( active: true, From 956a7aebc7b72f11bdd34fb6720f82fe20eafbad Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 12:16:23 -0600 Subject: [PATCH 25/35] =?UTF-8?q?unbounded:=20phase=204=20=E2=80=94=20firs?= =?UTF-8?q?t-visit=20welcome=20popup=20+=20info=20bubble?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the "Welcome to Unbounded" first-visit explainer dialog per Figma (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287). Fires automatically the first time the user opens the Unbounded tab, then never again — gated on unboundedWelcomeSeen (added to AppSetting in phase 2). The info-bubble icon in the tab header re-opens the same dialog so users can revisit the explanation. - New showUnboundedWelcomeDialog(context, ref): wraps a Dialog with the spec's heart-Lantern logo (re-using _HeartPainter), title, three-paragraph explainer body, and Learn more + Got it buttons. Dismissal (either button or scrim tap) flips welcomeSeen true via whenComplete so a single completion path handles both. - UnboundedTab.useEffect runs once on mount, schedules the dialog in a post-frame callback when welcomeSeen is false. - Description text row now also hosts an Icons.info_outline button to the right that calls showUnboundedWelcomeDialog directly. "Learn more" link is a no-op stub for now — wiring it to the public Unbounded explainer URL is a tiny followup once the URL is decided. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../share_my_connection.dart | 141 +++++++++++++++++- 1 file changed, 137 insertions(+), 4 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 48a93b53f1..ff2296a212 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -29,6 +29,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lottie/lottie.dart'; import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/unbounded_connection_event.dart'; +import 'package:lantern/features/home/provider/app_setting_notifier.dart'; import 'package:lantern/core/services/geo_lookup_service.dart'; import 'package:lantern/core/widgets/switch_button.dart'; import 'package:lantern/features/home/provider/radiance_settings_providers.dart'; @@ -502,16 +503,49 @@ class UnboundedTab extends HookConsumerWidget { final notifier = ref.read(shareProvider.notifier); final textTheme = Theme.of(context).textTheme; + // First-visit welcome popup. Fires once per device (persisted via + // appSettingProvider.unboundedWelcomeSeen) when the user first lands + // on the Unbounded tab. Re-openable via the info-bubble icon in the + // header. + useEffect(() { + final seen = ref.read(appSettingProvider).unboundedWelcomeSeen; + if (!seen) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + showUnboundedWelcomeDialog(context, ref); + }); + } + return null; + }, const []); + return SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( children: [ const SizedBox(height: 12), - Text( - 'Help others bypass censorship by securely sharing your ' - 'connection.', - style: textTheme.bodyMedium, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + 'Help others bypass censorship by securely sharing your ' + 'connection.', + style: textTheme.bodyMedium, + ), + ), + // Info bubble — re-opens the welcome popup. Mirrors the + // Figma spec, which calls out the info-bubble as the + // way back into the explanatory dialog. + IconButton( + icon: const Icon(Icons.info_outline, size: 20), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'About Unbounded', + onPressed: () => showUnboundedWelcomeDialog(context, ref), + ), + ], ), const SizedBox(height: 16), Expanded( @@ -1374,3 +1408,102 @@ class SmcDisclosureDialog extends StatelessWidget { ); } } + +// ─── Welcome dialog ────────────────────────────────────────────────────────── + +/// Shows the first-visit Unbounded welcome popup per Figma +/// (figma.com/design/hNlyYToB5TnX9SDBFDYJTq?node-id=2403-19287). +/// Idempotent: dismissing the dialog (either button OR scrim tap) +/// flips appSettingProvider.unboundedWelcomeSeen → true so the dialog +/// only fires on the first visit. The info-bubble icon in the +/// Unbounded tab header calls this same function to re-open it later. +void showUnboundedWelcomeDialog(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (_) => const _UnboundedWelcomeDialog(), + ).whenComplete(() { + ref.read(appSettingProvider.notifier).setUnboundedWelcomeSeen(true); + }); +} + +class _UnboundedWelcomeDialog extends StatelessWidget { + const _UnboundedWelcomeDialog(); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Heart logo, matching the Figma's heart-Lantern motif. + const Center( + child: SizedBox( + width: 40, + height: 34, + child: CustomPaint(painter: _HeartPainter()), + ), + ), + const SizedBox(height: 16), + Center( + child: Text( + 'Welcome to Unbounded', + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 16), + Text( + "When you enable Unbounded, your device becomes part of a " + "network of 'digital bridges' to the open internet. " + "Censored users connect to these bridges, allowing them " + "to bypass government-imposed restrictors and access the " + "information they need.", + style: textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'This collective effort makes censorship harder to ' + 'enforce, expanding access to the open internet.', + style: textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + 'You can remove Unbounded from the interface anytime in ' + 'Settings.', + style: textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: () { + // TODO: deep-link to the Unbounded explainer page + // once the URL is wired (AppUrls.unbounded?). + }, + icon: const Icon(Icons.open_in_new, size: 14), + label: const Text('Learn more'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Got it'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} From e22392c92198ab2b4ede4400369fdfbe6cdc5e39 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 12:25:00 -0600 Subject: [PATCH 26/35] unbounded: also auto-enable on app launch (not just VPN connect) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unbounded Settings subtitle reads "Turn on automatically when Lantern is open" — which is app-launch, not VPN-connect. Phase 3 only handled the VPN-connect transition, so a user who launches the app and never connects the VPN would never see Unbounded auto-start despite the toggle being on. Adds a second entry point: a post-frame useEffect on Home mount that reads autoEnable + onboardingCompleted, and calls ShareNotifier.autoStart if conditions hold. The existing ref.listen path stays in place for the case where the toggle flipped on after launch or the user connects the VPN later. Both paths gate on (active || probing) to avoid re-triggering mid-flight and skip the disclosure dialog since settings opt-in is the consent gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/features/home/home.dart | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 7e8fbb9ff4..24d23bd8db 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -94,11 +94,30 @@ class Home extends HookConsumerWidget { ref.read(appEventProvider); - // Auto-enable Unbounded on VPN-connected transitions, gated on the - // "Auto-enable Unbounded" toggle from Unbounded Settings. The actual - // start (UPnP probe + radiance peer_share_enabled setting) happens - // inside ShareNotifier.autoStart; the user has already consented in - // settings, so we skip the disclosure dialog. + // Auto-enable Unbounded — gated on the "Auto-enable Unbounded" + // toggle from Unbounded Settings (default ON). Fires from two + // entry points so the spec's subtitle "Turn on automatically when + // Lantern is open" is honoured whether the user connects the VPN + // or not: + // 1. App launch (useEffect below) — once on Home mount. + // 2. VPN connect (ref.listen further down) — on every + // disconnected → connected transition, in case the toggle + // flipped on after launch or the user finally connects. + // Both paths gate on (active || probing) to avoid re-triggering + // while a Start is in flight, and skip the disclosure dialog + // because the user has already opted in via settings. + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final appSetting = ref.read(appSettingProvider); + if (!appSetting.onboardingCompleted) return; + if (!appSetting.unboundedAutoEnable) return; + final share = ref.read(shareProvider); + if (share.active || share.probing) return; + ref.read(shareProvider.notifier).autoStart(ref); + }); + return null; + }, const []); + ref.listen(vpnProvider, (prev, next) { if (prev == next) return; if (next != VPNStatus.connected) return; From 5dc5f27cfc432c4aecd1e1d3755d7fe95145c57d Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 12:37:59 -0600 Subject: [PATCH 27/35] unbounded: lift the heart spray out of the pill, onto the globe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unbounded.lantern.io shows dozens of pink hearts spraying outward across the whole globe area on each arrival — not a single burst cramped inside the toast pill. Watching unbounded-russia.mp4 made it clear my previous implementation had the wrong scale: the Lottie was confined to a 40×40 slot inside the pill, so all the particle spray got clipped. Restructure: - New _LottieBurstLayer: a Positioned.fill overlay on top of the globe (sibling to _GlobeView, parent Stack now clipBehavior: Clip.none). Subscribes to ShareNotifier.connectionEvents and bumps a burstId counter on each non-replay state=1. The inner _BurstAnimation widget gets a fresh ValueKey per burst so the Lottie restarts from frame 0; the previous Lottie's AnimationController is disposed when the State unmounts. - _ArrivalCard simplified: replaces the embedded _HeartBurst with a static _HeartPainter heart, matching unbounded's pill chrome (small heart icon + text, no animation inside the pill). - _HeartBurst class removed. Result: the hearts now spread across the entire globe Stack area instead of being trapped inside a 40×40 box. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../share_my_connection.dart | 142 +++++++++++------- 1 file changed, 90 insertions(+), 52 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index ff2296a212..4e60ed54fc 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -551,13 +551,19 @@ class UnboundedTab extends HookConsumerWidget { Expanded( flex: 3, child: Stack( + clipBehavior: Clip.none, children: [ Positioned.fill(child: _GlobeView()), + // Lottie burst overlay — fills the globe area so the + // heart spray can spread across the whole sphere, + // matching unbounded.lantern.io. Plays once on each + // new arrival; the layer manages its own restart. + const Positioned.fill(child: _LottieBurstLayer()), // Floating arrival toast — overlays the bottom of the // globe area rather than the peer's exact location on - // the sphere. Anchoring to projected coords forced the - // burst to repaint every globe rotation frame, which - // made the rotation jittery. + // the sphere. Anchoring to projected coords forced + // the burst to repaint every globe rotation frame, + // which made the rotation jittery. const Positioned( left: 0, right: 0, @@ -1032,8 +1038,15 @@ class _ArrivalCard extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(width: 40, height: 40, child: _HeartBurst()), - const SizedBox(width: 12), + // Static heart inside the pill. The animated burst is its + // own layer covering the globe (see _LottieBurstLayer); the + // pill stays small and clean — matches unbounded.lantern.io. + const SizedBox( + width: 22, + height: 19, + child: CustomPaint(painter: _HeartPainter()), + ), + const SizedBox(width: 10), Text( flagEmoji.isEmpty ? 'Helping a new person in $countryName' @@ -1079,67 +1092,92 @@ class _WaitingCard extends StatelessWidget { } } -// ─── Heart burst ───────────────────────────────────────────────────────────── +// ─── Lottie burst layer ────────────────────────────────────────────────────── -/// Heart + Lottie explosion lifted from getlantern/unbounded. The pink -/// heart is the inline SVG path from `notification/explosion.tsx` -/// (FF5A79 fill, 32×27 viewBox); the burst is `explosion.json` played -/// once via the `lottie` Flutter package. Rendered inside _ArrivalCard -/// (under the globe), NOT anchored to globe coords — anchoring forced -/// a repaint per globe rotation frame and made rotation jittery. -class _HeartBurst extends StatefulWidget { - const _HeartBurst(); +/// Full-area Lottie heart-spray layer that overlays the globe and +/// plays the `explosion.json` animation each time a new peer arrives. +/// Matches unbounded.lantern.io's behaviour where dozens of small +/// hearts spray outward across the globe — NOT confined to the toast +/// pill (the pill stays clean with just a static heart icon). +/// +/// Subscribes to ShareNotifier.connectionEvents directly. On each +/// non-replay state=1 event, swaps the `key` on the inner Lottie via +/// a counter so AnimatedSwitcher cross-fades / Lottie restarts from +/// frame 0 every time. +class _LottieBurstLayer extends ConsumerStatefulWidget { + const _LottieBurstLayer(); @override - State<_HeartBurst> createState() => _HeartBurstState(); + ConsumerState<_LottieBurstLayer> createState() => _LottieBurstLayerState(); } -class _HeartBurstState extends State<_HeartBurst> - with TickerProviderStateMixin { - AnimationController? _lottieCtrl; +class _LottieBurstLayerState extends ConsumerState<_LottieBurstLayer> { + StreamSubscription? _sub; + int _burstId = 0; + + @override + void initState() { + super.initState(); + _sub = ref + .read(shareProvider.notifier) + .connectionEvents + .listen(_onEvent); + } + + void _onEvent(UnboundedConnectionEvent event) { + if (event.state != 1 || event.isReplay) return; + if (event.countryName.isEmpty) return; + if (!mounted) return; + setState(() => _burstId++); + } @override void dispose() { - _lottieCtrl?.dispose(); + _sub?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return IgnorePointer( - child: Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - // Lottie explosion sized so particle spray extends slightly - // past the card bounds (Clip.none on parent lets it overflow). - // Mirrors unbounded's LottieWrapper sizing, scaled down for an - // inline card slot. - Positioned( - width: 110, - height: 110, - child: Lottie.asset( - 'assets/unbounded/explosion.json', - repeat: false, - fit: BoxFit.contain, - onLoaded: (composition) { - _lottieCtrl = AnimationController( - vsync: this, - duration: composition.duration, - )..forward(); - setState(() {}); - }, - controller: _lottieCtrl, - ), - ), - // Heart SVG path — exact coords from unbounded's inline SVG. - const SizedBox( - width: 22, - height: 19, - child: CustomPaint(painter: _HeartPainter()), - ), - ], - ), + child: _burstId == 0 + ? const SizedBox.shrink() + : _BurstAnimation(key: ValueKey(_burstId)), + ); + } +} + +class _BurstAnimation extends StatefulWidget { + const _BurstAnimation({super.key}); + + @override + State<_BurstAnimation> createState() => _BurstAnimationState(); +} + +class _BurstAnimationState extends State<_BurstAnimation> + with TickerProviderStateMixin { + AnimationController? _ctrl; + + @override + void dispose() { + _ctrl?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Lottie.asset( + 'assets/unbounded/explosion.json', + repeat: false, + fit: BoxFit.contain, + onLoaded: (composition) { + _ctrl = AnimationController( + vsync: this, + duration: composition.duration, + )..forward(); + setState(() {}); + }, + controller: _ctrl, ); } } From dec39fac00ec2019d065b8b0ba6310f9cb28d439 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 13:00:42 -0600 Subject: [PATCH 28/35] =?UTF-8?q?unbounded:=20put=20the=20Lottie=20inside?= =?UTF-8?q?=20the=20pill,=20overflowing=20=E2=80=94=20matches=20CSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous approach made the Lottie a globe-wide Positioned.fill layer. unbounded.lantern.io actually anchors the Lottie INSIDE the toast pill's heart slot, with absolute-positioned negative offsets so it overflows up and to the right into the globe area: LottieContainer { position: relative; width: 32px; height: 27px; } LottieWrapper { position: absolute; bottom: -55px; left: -105px; width: 420px; } Translating one-to-one in Flutter: the pill's heart slot is a Stack with clipBehavior: Clip.none, containing the static _HeartPainter centered + a Positioned _ArrivalLottie at bottom: -55, left: -105, width: 420, height: 420. The pill Container itself also uses clipBehavior: Clip.none so the Lottie can spill past the rounded borders. Side benefits: - The burst now follows the pill — when AnimatedSwitcher swaps to a new arrival card, the Lottie restarts naturally because each card has its own _ArrivalLottie state (no need for the burstId counter + the standalone _LottieBurstLayer, both deleted). - The burst origin is anchored at the pill's heart, so hearts spray from a single, semantically-meaningful point instead of centre-of-globe. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../share_my_connection.dart | 173 ++++++++---------- 1 file changed, 72 insertions(+), 101 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 4e60ed54fc..a91b32b8e6 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -554,16 +554,13 @@ class UnboundedTab extends HookConsumerWidget { clipBehavior: Clip.none, children: [ Positioned.fill(child: _GlobeView()), - // Lottie burst overlay — fills the globe area so the - // heart spray can spread across the whole sphere, - // matching unbounded.lantern.io. Plays once on each - // new arrival; the layer manages its own restart. - const Positioned.fill(child: _LottieBurstLayer()), // Floating arrival toast — overlays the bottom of the // globe area rather than the peer's exact location on - // the sphere. Anchoring to projected coords forced - // the burst to repaint every globe rotation frame, - // which made the rotation jittery. + // the sphere. The Lottie heart-spray now lives INSIDE + // the toast pill (with negative-offset positioning so + // it overflows upward/rightward into the globe area), + // mirroring unbounded's CSS: the static heart anchors + // the burst, hearts spray outward from there. const Positioned( left: 0, right: 0, @@ -1023,6 +1020,10 @@ class _ArrivalCard extends StatelessWidget { return IgnorePointer( child: Container( padding: const EdgeInsets.fromLTRB(10, 8, 16, 8), + // clipBehavior:none lets the absolutely-positioned Lottie burst + // (inside the heart slot below) overflow the pill's rounded + // bounds and spray upward across the globe. + clipBehavior: Clip.none, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.92), borderRadius: BorderRadius.circular(100), @@ -1038,13 +1039,34 @@ class _ArrivalCard extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - // Static heart inside the pill. The animated burst is its - // own layer covering the globe (see _LottieBurstLayer); the - // pill stays small and clean — matches unbounded.lantern.io. - const SizedBox( + // Heart slot with the static heart icon + a Lottie burst + // that overflows upward and rightward into the globe area. + // Layout mirrors unbounded's CSS one-for-one: heart in a + // 22×19 slot, Lottie absolute-positioned at bottom:-55, + // left:-105, width:420 (scaled to the slot's natural + // bottom/left = pill heart's bottom/left). + SizedBox( width: 22, height: 19, - child: CustomPaint(painter: _HeartPainter()), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: const [ + // Lottie spreads upward + rightward from the heart. + // height is auto from BoxFit.contain on its 420-wide + // canvas; explosion.json is roughly square so ~420 + // tall, but most of that is above the heart due to + // the negative bottom offset. + Positioned( + bottom: -55, + left: -105, + width: 420, + height: 420, + child: _ArrivalLottie(), + ), + CustomPaint(painter: _HeartPainter()), + ], + ), ), const SizedBox(width: 10), Text( @@ -1063,98 +1085,16 @@ class _ArrivalCard extends StatelessWidget { } } -/// Idle-state companion to _ArrivalCard. Same pill chrome, no heart, -/// `Waiting for connections...` text. Shown whenever the toast switch -/// has no current arrival to display. -class _WaitingCard extends StatelessWidget { - const _WaitingCard({super.key}); - - @override - Widget build(BuildContext context) { - return IgnorePointer( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.92), - borderRadius: BorderRadius.circular(100), - border: Border.all(color: Colors.black12), - ), - child: Text( - 'Waiting for connections...', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Theme.of(context).hintColor, - ), - ), - ), - ); - } -} - -// ─── Lottie burst layer ────────────────────────────────────────────────────── - -/// Full-area Lottie heart-spray layer that overlays the globe and -/// plays the `explosion.json` animation each time a new peer arrives. -/// Matches unbounded.lantern.io's behaviour where dozens of small -/// hearts spray outward across the globe — NOT confined to the toast -/// pill (the pill stays clean with just a static heart icon). -/// -/// Subscribes to ShareNotifier.connectionEvents directly. On each -/// non-replay state=1 event, swaps the `key` on the inner Lottie via -/// a counter so AnimatedSwitcher cross-fades / Lottie restarts from -/// frame 0 every time. -class _LottieBurstLayer extends ConsumerStatefulWidget { - const _LottieBurstLayer(); - - @override - ConsumerState<_LottieBurstLayer> createState() => _LottieBurstLayerState(); -} - -class _LottieBurstLayerState extends ConsumerState<_LottieBurstLayer> { - StreamSubscription? _sub; - int _burstId = 0; - - @override - void initState() { - super.initState(); - _sub = ref - .read(shareProvider.notifier) - .connectionEvents - .listen(_onEvent); - } - - void _onEvent(UnboundedConnectionEvent event) { - if (event.state != 1 || event.isReplay) return; - if (event.countryName.isEmpty) return; - if (!mounted) return; - setState(() => _burstId++); - } - - @override - void dispose() { - _sub?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return IgnorePointer( - child: _burstId == 0 - ? const SizedBox.shrink() - : _BurstAnimation(key: ValueKey(_burstId)), - ); - } -} - -class _BurstAnimation extends StatefulWidget { - const _BurstAnimation({super.key}); +/// Plays explosion.json once per build. Stateful so each _ArrivalCard +/// instance (keyed on workerIdx) gets its own clean Lottie playback. +class _ArrivalLottie extends StatefulWidget { + const _ArrivalLottie(); @override - State<_BurstAnimation> createState() => _BurstAnimationState(); + State<_ArrivalLottie> createState() => _ArrivalLottieState(); } -class _BurstAnimationState extends State<_BurstAnimation> +class _ArrivalLottieState extends State<_ArrivalLottie> with TickerProviderStateMixin { AnimationController? _ctrl; @@ -1182,6 +1122,37 @@ class _BurstAnimationState extends State<_BurstAnimation> } } +/// Idle-state companion to _ArrivalCard. Same pill chrome, no heart, +/// `Waiting for connections...` text. Shown whenever the toast switch +/// has no current arrival to display. +class _WaitingCard extends StatelessWidget { + const _WaitingCard({super.key}); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(100), + border: Border.all(color: Colors.black12), + ), + child: Text( + 'Waiting for connections...', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).hintColor, + ), + ), + ), + ); + } +} + +// ─── Lottie burst layer ────────────────────────────────────────────────────── + /// Pink heart from `getlantern/unbounded` — exact SVG path coords /// (viewBox 0 0 32 27, fill #FF5A79). class _HeartPainter extends CustomPainter { From e2c89cf8b1b8451215f50ca326f3a732c34c9e85 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 13:15:58 -0600 Subject: [PATCH 29/35] =?UTF-8?q?unbounded:=20match=20the=20pill=20exactly?= =?UTF-8?q?=20=E2=80=94=20heart+text,=20bottom-left=20anchor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compared the current implementation against frame-020.png of unbounded-russia.mp4: - The pill in unbounded is just [heart icon] + text, no flag emoji. Removed the flag prefix so the pill width stays manageable and the layout reads identically. flagEmoji is still on the event for future use (label above the arc, etc). - Anchor the pill at the bottom-LEFT of the globe area, not centered. Position changes from (left: 0, right: 0, child: Center(...)) to (left: 12, bottom: 8, child: ...). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../share_my_connection.dart | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index a91b32b8e6..1bc00cd5f2 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -554,18 +554,17 @@ class UnboundedTab extends HookConsumerWidget { clipBehavior: Clip.none, children: [ Positioned.fill(child: _GlobeView()), - // Floating arrival toast — overlays the bottom of the - // globe area rather than the peer's exact location on - // the sphere. The Lottie heart-spray now lives INSIDE - // the toast pill (with negative-offset positioning so - // it overflows upward/rightward into the globe area), - // mirroring unbounded's CSS: the static heart anchors - // the burst, hearts spray outward from there. + // Floating arrival toast — anchored at the bottom-LEFT + // of the globe area per unbounded.lantern.io. The + // Lottie heart-spray lives INSIDE the pill (negative- + // offset positioning, overflows upward/rightward into + // the globe), mirroring unbounded's CSS: the static + // heart anchors the burst origin, hearts spray + // outward from there. const Positioned( - left: 0, - right: 0, + left: 12, bottom: 8, - child: Center(child: _ArrivalToast()), + child: _ArrivalToast(), ), ], ), @@ -1069,10 +1068,13 @@ class _ArrivalCard extends StatelessWidget { ), ), const SizedBox(width: 10), + // unbounded.lantern.io renders just `heart + text`, no flag + // emoji — matching that exactly so the pill width stays in + // bounds and the layout reads cleanly. flagEmoji is still + // carried on the event for future use (e.g. label above + // the peer's arc on the globe). Text( - flagEmoji.isEmpty - ? 'Helping a new person in $countryName' - : '$flagEmoji Helping a new person in $countryName', + 'Helping a new person in $countryName', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w500, From 0b9965e3ca362e30502cccb34e924f8029bee1fe Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 13:33:18 -0600 Subject: [PATCH 30/35] unbounded: revert the pill back to centered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit moved the pill to bottom-left, overshooting the fix for the cut-off text — the actual cause was the extra flag-emoji width, which is already removed. Restoring (left: 0, right: 0, child: Center(...)) so the pill sits under the globe's centre per frame-020 of unbounded-russia.mp4. Static heart in the pill stays visible (also matches unbounded) and continues to anchor the Lottie burst origin. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../share_my_connection.dart | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 1bc00cd5f2..f571cc91e5 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -554,17 +554,19 @@ class UnboundedTab extends HookConsumerWidget { clipBehavior: Clip.none, children: [ Positioned.fill(child: _GlobeView()), - // Floating arrival toast — anchored at the bottom-LEFT - // of the globe area per unbounded.lantern.io. The - // Lottie heart-spray lives INSIDE the pill (negative- - // offset positioning, overflows upward/rightward into - // the globe), mirroring unbounded's CSS: the static - // heart anchors the burst origin, hearts spray - // outward from there. + // Floating arrival toast — centered horizontally + // under the globe per unbounded.lantern.io + // (frame-020 of unbounded-russia.mp4 shows the pill + // sitting roughly under the globe's centre, not at + // a corner). The Lottie heart-spray lives INSIDE the + // pill via Stack(Clip.none) + negative offsets, so + // hearts originate from the pill's static heart and + // overflow upward/leftward into the globe area. const Positioned( - left: 12, + left: 0, + right: 0, bottom: 8, - child: _ArrivalToast(), + child: Center(child: _ArrivalToast()), ), ], ), From c6a025e454a8f9e8e3a9e9ec729e8743eea3c925 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Tue, 12 May 2026 13:39:11 -0600 Subject: [PATCH 31/35] unbounded: persist "Total people helped to date" across restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stat was an in-memory counter that reset on every app launch and on every off→on toggle. Spec wording ("Total people helped to date") implies lifetime — survives both. - AppSetting gains unboundedTotalHelped (int, default 0) + the matching setUnboundedTotalHelped notifier method. Round-trips via toJson/fromJson. - ShareNotifier.build() seeds totalCount from the persisted value instead of starting at 0. - _start and _stop now preserve state.totalCount across toggle cycles (were overwriting with ShareState() defaults). - On each new-peer arrival, after incrementing totalCount, write the new value via setUnboundedTotalHelped so the persisted value stays in sync. SharedPreferences I/O is fine — peer arrivals are bursty, not continuous. - Stat labels updated to the Figma copy: "People helping right now" (was "Active now") and "Total people helped to date" (was "Total today" — which was inaccurate even before persistence). Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/core/models/app_setting.dart | 13 +++++++ .../home/provider/app_setting_notifier.dart | 3 ++ .../share_my_connection.dart | 34 +++++++++++++++---- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/lib/core/models/app_setting.dart b/lib/core/models/app_setting.dart index a86bb1ccc0..12fd7acaa8 100644 --- a/lib/core/models/app_setting.dart +++ b/lib/core/models/app_setting.dart @@ -16,6 +16,12 @@ class AppSetting { final bool unboundedAutoEnable; final bool unboundedHidden; final bool unboundedWelcomeSeen; + // Lifetime running total of peers this device has helped. Survives + // restarts so the "Total people helped to date" stat in the + // Unbounded tab can keep climbing — that's the spec wording in the + // Figma. ShareNotifier seeds totalCount from this on build, and + // writes back each time the count increments. + final int unboundedTotalHelped; const AppSetting({ this.themeMode = 'system', @@ -30,6 +36,7 @@ class AppSetting { this.unboundedAutoEnable = true, this.unboundedHidden = false, this.unboundedWelcomeSeen = false, + this.unboundedTotalHelped = 0, }); AppSetting copyWith({ @@ -45,6 +52,7 @@ class AppSetting { bool? unboundedAutoEnable, bool? unboundedHidden, bool? unboundedWelcomeSeen, + int? unboundedTotalHelped, }) { return AppSetting( locale: newLocale ?? locale, @@ -59,6 +67,8 @@ class AppSetting { unboundedAutoEnable: unboundedAutoEnable ?? this.unboundedAutoEnable, unboundedHidden: unboundedHidden ?? this.unboundedHidden, unboundedWelcomeSeen: unboundedWelcomeSeen ?? this.unboundedWelcomeSeen, + unboundedTotalHelped: + unboundedTotalHelped ?? this.unboundedTotalHelped, ); } @@ -75,6 +85,7 @@ class AppSetting { 'unboundedAutoEnable': unboundedAutoEnable, 'unboundedHidden': unboundedHidden, 'unboundedWelcomeSeen': unboundedWelcomeSeen, + 'unboundedTotalHelped': unboundedTotalHelped, }; factory AppSetting.fromJson(Map json) => AppSetting( @@ -92,5 +103,7 @@ class AppSetting { unboundedAutoEnable: json['unboundedAutoEnable'] != false, unboundedHidden: json['unboundedHidden'] == true, unboundedWelcomeSeen: json['unboundedWelcomeSeen'] == true, + unboundedTotalHelped: + (json['unboundedTotalHelped'] as num?)?.toInt() ?? 0, ); } diff --git a/lib/features/home/provider/app_setting_notifier.dart b/lib/features/home/provider/app_setting_notifier.dart index 4738642a36..ff9f232047 100644 --- a/lib/features/home/provider/app_setting_notifier.dart +++ b/lib/features/home/provider/app_setting_notifier.dart @@ -114,6 +114,9 @@ class AppSettingNotifier extends _$AppSettingNotifier { void setUnboundedWelcomeSeen(bool value) => update(state.copyWith(unboundedWelcomeSeen: value)); + void setUnboundedTotalHelped(int value) => + update(state.copyWith(unboundedTotalHelped: value)); + Future _writeInitMarker() async { try { final dataDir = await AppStorageUtils.getAppDirectory(); diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index f571cc91e5..352d01295b 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -158,7 +158,13 @@ class ShareNotifier extends Notifier { _stopEventSubscription(); _eventController.close(); }); - return const ShareState(); + // Seed totalCount from the persisted lifetime running total so the + // "Total people helped to date" stat survives app restarts. New + // arrivals (line further down) increment both ShareState.totalCount + // and the persisted value via setUnboundedTotalHelped. + final persistedTotal = + ref.read(appSettingProvider).unboundedTotalHelped; + return ShareState(totalCount: persistedTotal); } /// Toggle entry point. Caller passes its BuildContext so we can show the @@ -262,7 +268,9 @@ class ShareNotifier extends Notifier { probing: false, mode: mode, activeCount: 0, - totalCount: 0, + // Preserve the running total across off→on cycles so toggling + // doesn't reset the user's lifetime count. + totalCount: state.totalCount, ); _startEventSubscription(widgetRef); switch (mode) { @@ -299,7 +307,9 @@ class ShareNotifier extends Notifier { Future _stop(WidgetRef widgetRef) async { _stopEventSubscription(); final priorMode = state.mode; - state = const ShareState(); + // Preserve totalCount across toggle-off (same reason as _start — + // user's lifetime count shouldn't reset on a toggle cycle). + state = ShareState(totalCount: state.totalCount); switch (priorMode) { case ShareMode.smc: await widgetRef @@ -356,10 +366,18 @@ class ShareNotifier extends Notifier { final widx = _workerSeq++; final arc = _PeerArc(widx); _peerArcs[ip] = arc; + final newTotal = state.totalCount + 1; state = state.copyWith( activeCount: state.activeCount + 1, - totalCount: state.totalCount + 1, + totalCount: newTotal, ); + // Persist so the "Total people helped to date" stat + // survives restarts. Write happens per-arrival, but arrivals + // are bursty rather than continuous so SharedPreferences I/O + // pressure is fine. + ref + .read(appSettingProvider.notifier) + .setUnboundedTotalHelped(newTotal); // Resolve country async. Emit the +1 only after lookup so the // globe can render the arc at the right coords and the UI can // surface the country name in the connection banner. @@ -686,8 +704,12 @@ class _StatusCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _Stat(label: 'Active now', value: '${state.activeCount}'), - _Stat(label: 'Total today', value: '${state.totalCount}'), + _Stat( + label: 'People helping right now', + value: '${state.activeCount}'), + _Stat( + label: 'Total people helped to date', + value: '${state.totalCount}'), ], ), Positioned( From adeb9e5ad671b98fbe6e52904f3d3b67eed330b3 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 13 May 2026 13:03:57 -0600 Subject: [PATCH 32/35] share: auto-fall-back from SmC to Unbounded on any Start failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit peer.Client.Start failures (UPnP miss, /v1/peer/register 404/4xx/5xx, samizdat verify timeout) arrive in Dart as a peer-status FlutterEvent with phase=error. Until now those rendered raw inside the SmC status card ("Couldn't share: register with lantern-cloud: register: peer api: status=404 body=404 page not found"), which is both ugly and inactionable. Now `_handlePeerStatus` detects phase==error with mode==SmC and transparently switches to Unbounded via setUnboundedEnabled(true). The user's intent — "I want to share" — is honoured via broflake regardless of SmC's outcome. UPnP failure is the common case; treating it as a routine fallback rather than an error matches the design expectation that UPnP works only some of the time. State is rebuilt with ShareState() directly (rather than copyWith) so errorMessage clears — copyWith's `?? this.errorMessage` would otherwise keep the stale SmC failure string visible after the fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../share_my_connection.dart | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 352d01295b..421a6a2f3c 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -345,7 +345,7 @@ class ShareNotifier extends Notifier { .watchAppEvents() .listen((event) { if (event.eventType == 'peer-status') { - _handlePeerStatus(event.message); + _handlePeerStatus(event.message, widgetRef); return; } if (event.eventType != 'peer-connection') return; @@ -488,11 +488,25 @@ class ShareNotifier extends Notifier { // from radiance/peer/peer.go's Phase constants; we map them through // SharePhase.fromWire so an unknown future phase falls back to idle // instead of crashing the consumer. - void _handlePeerStatus(String message) { + // + // SmC Start failures (UPnP miss, /v1/peer/register 404/4xx/5xx, + // samizdat verify timeout, …) arrive as phase=error. Treat any such + // failure as a signal to switch transparently to Unbounded mode — + // the user's intent ("I want to share") is honoured via broflake + // regardless of SmC's outcome, and raw protocol error text never + // reaches the status card. + void _handlePeerStatus(String message, WidgetRef widgetRef) { try { final payload = jsonDecode(message) as Map; final phase = SharePhase.fromWire(payload['phase'] as String?); final errMsg = payload['error'] as String?; + if (phase == SharePhase.error && state.mode == ShareMode.smc) { + appLogger.info( + 'SmC start failed, falling back to Unbounded: ${errMsg ?? ""}', + ); + unawaited(_fallbackToUnbounded(widgetRef)); + return; + } state = state.copyWith( phase: phase, errorMessage: (errMsg == null || errMsg.isEmpty) ? null : errMsg, @@ -501,6 +515,35 @@ class ShareNotifier extends Notifier { debugPrint('share-my-connection: bad peer-status event: $e'); } } + + // Seamlessly switches an in-flight SmC session to Unbounded. Called when + // the radiance peer client reports phase=error — the SmC Start has + // already failed and radiance has rolled the PeerShareEnabledKey + // setting back to false, so all we owe is to flip our local state to + // Unbounded and enable broflake. + // + // Constructs ShareState directly (rather than copyWith) so errorMessage + // gets cleared — copyWith's `?? this.errorMessage` keeps the previous + // SmC failure string around otherwise. + Future _fallbackToUnbounded(WidgetRef widgetRef) async { + state = ShareState( + active: true, + probing: false, + mode: ShareMode.unbounded, + activeCount: 0, + totalCount: state.totalCount, + phase: SharePhase.idle, + ); + final result = await widgetRef + .read(lanternServiceProvider) + .setUnboundedEnabled(true); + result.fold( + (err) => appLogger.error( + 'SmC→Unbounded fallback: setUnboundedEnabled failed: ${err.error}', + ), + (_) => {}, + ); + } } final shareProvider = From 407f7fa996406fb9d48a776a5731842b3c0488b5 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Wed, 13 May 2026 13:46:43 -0600 Subject: [PATCH 33/35] unbounded: gate entire UI surface on server Features[unbounded] flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Censored users should not see a "share your connection" UI on their device — it can be a red flag on-device evidence even when broflake itself is server-gated off. Mirror the radiance shouldRunUnbounded gate up into Flutter so the Unbounded tab, settings sub-page, project promo tile, first-visit welcome dialog, and auto-enable hooks all disappear when Features[unbounded] is false. Adds FeatureFlag.unbounded backed by the same "unbounded" key the server already emits (common/types.go UNBOUNDED). Default getBool(...) is false, so any user whose /v1/config-new response omits the flag (no connectivity, parse failure, censored region) sees the safe state: no Unbounded UI at all. The user's "Hide Unbounded tab" toggle (appSettingProvider unboundedHidden) still wins on top of this for non-censored users who want it hidden. The new effective predicate is unboundedAvailable && !unboundedHidden. The welcome dialog at share_my_connection.dart:572 and the info-bubble re-opener at :607 are both inside UnboundedTab.build, which never mounts when the tab is hidden, so no defensive code is needed there. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/core/models/feature_flags.dart | 10 +++- lib/features/home/home.dart | 21 ++++++--- lib/features/setting/setting.dart | 75 +++++++++++++++++------------- 3 files changed, 67 insertions(+), 39 deletions(-) diff --git a/lib/core/models/feature_flags.dart b/lib/core/models/feature_flags.dart index 91052949f7..70787331bb 100644 --- a/lib/core/models/feature_flags.dart +++ b/lib/core/models/feature_flags.dart @@ -3,7 +3,15 @@ enum FeatureFlag { metrics('otel.metrics'), traces('otel.traces'), autoUpdateEnabled('autoUpdateEnabled'), - androidSideloadAutoUpdateEnabled('androidSideloadAutoUpdateEnabled'); + androidSideloadAutoUpdateEnabled('androidSideloadAutoUpdateEnabled'), + // Server-side gate for the entire Unbounded / Share My Connection + // surface. When false (the default for censored regions), the + // Unbounded tab, settings entry, project link, and auto-enable hooks + // all disappear — censored users should never see a "share your + // connection" UI that could draw attention to them on-device. Mirrors + // radiance/unbounded/unbounded.go shouldRunUnbounded, which already + // gates execution on the same Features[UNBOUNDED] flag. + unbounded('unbounded'); final String key; diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 24d23bd8db..ed0465d9b7 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -41,6 +41,12 @@ class Home extends HookConsumerWidget { appSettingProvider.select((s) => s.unboundedHidden), ); final featureFlag = ref.watch(featureFlagProvider); + // Server-side gate for the whole Unbounded UI surface. Censored + // regions get the flag off, so the tab, strip, and any auto-enable + // hook disappear. The user's own "Hide Unbounded tab" toggle still + // wins on top of this for non-censored users who want it hidden. + final unboundedAvailable = featureFlag.getBool(FeatureFlag.unbounded); + final showUnboundedTab = unboundedAvailable && !unboundedHidden; final vpnStatus = ref.watch(vpnProvider); final shareActive = ref.watch(shareProvider.select((s) => s.active)); @@ -108,6 +114,7 @@ class Home extends HookConsumerWidget { // because the user has already opted in via settings. useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) { + if (!unboundedAvailable) return; final appSetting = ref.read(appSettingProvider); if (!appSetting.onboardingCompleted) return; if (!appSetting.unboundedAutoEnable) return; @@ -116,11 +123,12 @@ class Home extends HookConsumerWidget { ref.read(shareProvider.notifier).autoStart(ref); }); return null; - }, const []); + }, [unboundedAvailable]); ref.listen(vpnProvider, (prev, next) { if (prev == next) return; if (next != VPNStatus.connected) return; + if (!unboundedAvailable) return; final autoEnable = ref.read(appSettingProvider).unboundedAutoEnable; if (!autoEnable) return; @@ -166,10 +174,11 @@ class Home extends HookConsumerWidget { onPressed: () => appRouter.push(const SignInEmail()), ), ], - // Tab strip collapses when the user has hidden the Unbounded - // tab in Unbounded Settings — with only one tab left, a strip - // is just noise. Body falls back to VpnTab directly. - bottom: unboundedHidden + // Tab strip collapses when Unbounded is unavailable — either the + // server flag is off (censored region) or the user hid the tab + // in Unbounded Settings. With only one tab left, a strip is just + // noise; body falls back to VpnTab directly. + bottom: !showUnboundedTab ? null : TabBar( controller: tabController, @@ -181,7 +190,7 @@ class Home extends HookConsumerWidget { ], ), ), - body: unboundedHidden + body: !showUnboundedTab ? const VpnTab() : TabBarView( controller: tabController, diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index eb507518aa..1b9b097521 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -7,7 +7,9 @@ import 'package:lantern/core/localization/localization_constants.dart'; import 'package:lantern/core/updater/updater.dart'; import 'package:lantern/core/utils/pro_utils.dart'; import 'package:lantern/core/widgets/subscription_tags.dart'; +import 'package:lantern/core/models/feature_flags.dart'; import 'package:lantern/features/home/provider/app_setting_notifier.dart'; +import 'package:lantern/features/home/provider/feature_flag_notifier.dart'; import 'package:lantern/features/home/provider/home_notifier.dart'; import 'package:lantern/features/plans/restore_purchase_mixin.dart'; import 'package:lantern/features/setting/appearance.dart' @@ -63,6 +65,11 @@ class _SettingState extends ConsumerState final email = ref.watch(userEmailProvider); final appSetting = ref.watch(appSettingProvider); + // Server-side gate. Censored regions get Features[unbounded]=false, + // and every Unbounded-flavoured row in this menu (the settings sub- + // page link AND the project promo card at the bottom) disappears. + final unboundedAvailable = + ref.watch(featureFlagProvider).getBool(FeatureFlag.unbounded); final hasProSession = (user?.legacyUserData.isPro ?? false) && @@ -144,13 +151,15 @@ class _SettingState extends ConsumerState icon: AppImagePaths.glob, onPressed: () => settingMenuTap(_SettingType.vpnSetting), ), - DividerSpace(), - AppTile( - label: 'Unbounded Settings', - icon: AppImagePaths.share, - onPressed: () => - settingMenuTap(_SettingType.unboundedSetting), - ), + if (unboundedAvailable) ...[ + DividerSpace(), + AppTile( + label: 'Unbounded Settings', + icon: AppImagePaths.share, + onPressed: () => + settingMenuTap(_SettingType.unboundedSetting), + ), + ], DividerSpace(), AppTile( label: 'language'.i18n, @@ -242,36 +251,38 @@ class _SettingState extends ConsumerState ), ), }, - const SizedBox(height: defaultSize), - Padding( - padding: const EdgeInsets.only(left: 16), - child: Text( - 'lantern_projects'.i18n, - style: textTheme.labelLarge!.copyWith( - color: context.textSecondary, + if (unboundedAvailable) ...[ + const SizedBox(height: defaultSize), + Padding( + padding: const EdgeInsets.only(left: 16), + child: Text( + 'lantern_projects'.i18n, + style: textTheme.labelLarge!.copyWith( + color: context.textSecondary, + ), ), ), - ), - const SizedBox(height: 4), - Card( - child: AppTile( - minHeight: 72, - icon: AppImagePaths.lanternLogoRounded, - iconUseThemeColor: false, - trailing: AppImage(path: AppImagePaths.outsideBrowser), - label: 'unbounded'.i18n, - subtitle: Text( - 'help_fight_global_internet_censorship'.i18n, - style: textTheme.labelMedium!.copyWith( - color: context.textTertiary, + const SizedBox(height: 4), + Card( + child: AppTile( + minHeight: 72, + icon: AppImagePaths.lanternLogoRounded, + iconUseThemeColor: false, + trailing: AppImage(path: AppImagePaths.outsideBrowser), + label: 'unbounded'.i18n, + subtitle: Text( + 'help_fight_global_internet_censorship'.i18n, + style: textTheme.labelMedium!.copyWith( + color: context.textTertiary, + ), ), + onPressed: () { + UrlUtils.openUrl(AppUrls.unbounded); + }, ), - onPressed: () { - UrlUtils.openUrl(AppUrls.unbounded); - }, ), - ), - SizedBox(height: defaultSize), + SizedBox(height: defaultSize), + ], ], ), ); From dca50d22e3a21436aa511d4c132398aae3a6327e Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 14 May 2026 15:43:53 -0600 Subject: [PATCH 34/35] share: render Lottie arrival heart-burst at native canvas size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lottie's explosion.json is 420×502; we were forcing it into a 420×420 Positioned with BoxFit.contain, which uniform-scaled the animation down by ~83% and lopped 82 px off the upward spread. End result: the hearts clustered tightly just above the pill instead of fanning out across the globe the way unbounded.lantern.io's CSS renders them (width:420 with height:auto preserves the native aspect ratio). Set height to 502 to match the native canvas exactly. Width and the bottom/left negative offsets stay the same — the bottom of the Lottie still anchors 55 px below the pill heart's bottom and 105 px left of its left edge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../share_my_connection/share_my_connection.dart | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index 421a6a2f3c..d915a1767e 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -1119,15 +1119,19 @@ class _ArrivalCard extends StatelessWidget { alignment: Alignment.center, children: const [ // Lottie spreads upward + rightward from the heart. - // height is auto from BoxFit.contain on its 420-wide - // canvas; explosion.json is roughly square so ~420 - // tall, but most of that is above the heart due to - // the negative bottom offset. + // The size matches explosion.json's native 420×502 + // canvas — unbounded.lantern.io's CSS uses width:420 + // with height:auto for the same effect. Forcing the + // height to 420 (as we did before) scaled the + // animation down by ~83% via BoxFit.contain and lost + // ~82px of upward spread, leaving the hearts visibly + // smaller and clustered just above the pill instead + // of fanning out across the globe. Positioned( bottom: -55, left: -105, width: 420, - height: 420, + height: 502, child: _ArrivalLottie(), ), CustomPaint(painter: _HeartPainter()), From a2438f2e21fe783037f76ce53a5f5227998c8d75 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Thu, 14 May 2026 16:09:23 -0600 Subject: [PATCH 35/35] =?UTF-8?q?share:=20nudge=20heart-to-text=20gap=20fr?= =?UTF-8?q?om=2010=20=E2=86=92=2014=20px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill's static heart was sitting a touch close to the "H" in "Helping a new person in ". 4 px is the smallest visibly noticeable nudge — large enough to ease the crowding without making the pill feel padded. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/features/share_my_connection/share_my_connection.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/share_my_connection/share_my_connection.dart b/lib/features/share_my_connection/share_my_connection.dart index d915a1767e..3a175dc52a 100644 --- a/lib/features/share_my_connection/share_my_connection.dart +++ b/lib/features/share_my_connection/share_my_connection.dart @@ -1138,7 +1138,7 @@ class _ArrivalCard extends StatelessWidget { ], ), ), - const SizedBox(width: 10), + const SizedBox(width: 14), // unbounded.lantern.io renders just `heart + text`, no flag // emoji — matching that exactly so the pill width stays in // bounds and the layout reads cleanly. flagEmoji is still