Skip to content

Unified Share My Connection screen with globe, Advanced section, and Unbounded as Basic mode (Part 1/2) - #8819

Open
myleshorton wants to merge 38 commits into
mainfrom
fisk/smc-unified-screen
Open

Unified Share My Connection screen with globe, Advanced section, and Unbounded as Basic mode (Part 1/2)#8819
myleshorton wants to merge 38 commits into
mainfrom
fisk/smc-unified-screen

Conversation

@myleshorton

@myleshorton myleshorton commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

End-to-end UX for Share My Connection — one unified screen, one toggle, one globe — with both donor modes wired through:

  • Full mode (SmC): samizdat over UPnP / manual-port forwarding; persistent residential exit.
  • Basic mode (Unbounded): broflake / WebRTC ephemeral peer sessions.

The user picks between modes via a one-time disclosure dialog. Both protocols emit identically-shaped EventTypePeerConnection FlutterEvents that the globe consumes — protocol-agnostic rendering.

This is Part 1 of 2 of the original #8740 split. Part 2 ships Unbounded as a top-level home tab in a follow-up.

What's in this PR

Screen (lib/features/share_my_connection/share_my_connection.dart):

  • Replaces the inline SmC toggle in vpn_setting.dart with a navigation tile to a dedicated screen.
  • Globe (Jigar's flutter_earth_globe integration from Unbounded changes #8493, reused verbatim, MediaQuery-overridden to centre in-widget).
  • Status card with mode badge + active/total counters.
  • One-time SmC disclosure dialog ("Full mode" / "Basic mode").
  • Advanced section (collapsed by default): manual port-forward field for users on UPnP-less networks; persists via setPeerManualPort FFI, takes effect on next toggle-on.
  • Per-peer geolocation rendering on the globe + arc reversal.
  • Lottie heart-burst toast on remote-client arrival, lifted off the globe so it's not clipped by viewport bounds.

Backend (Go, lantern-core/):

  • Core.SetPeerManualPort / GetPeerManualPortPatchSettings(PeerManualPortKey: int).
  • Core.SetUnboundedEnabled / IsUnboundedEnabledPatchSettings(UnboundedKey: bool).
  • listenPeerConnectionEvents subscribes to both peer.ConnectionEvent (samizdat) and unbounded.ConnectionEvent (broflake), forwards as one EventTypePeerConnection FlutterEvent — globe is protocol-agnostic.
  • 4 new //export FFI functions: setPeerManualPort, getPeerManualPort, setUnboundedEnabled, isUnboundedEnabled.
  • Mobile error sanitization before returning to gomobile bridge (prevents "object" leakage into Flutter logs).
  • Consumes peer events over IPC SSE instead of in-process events.Subscribe — same wire format as the rest of the FlutterEvent bridge.

Frontend (Dart):

  • Hand-rolled FFI bindings for the 4 new exports.
  • LanternCoreService interface, FFI impl, Service router, Platform stub all gain the new methods. Platform stub returns "not implemented" — iOS / Android MethodChannel handlers aren't plumbed yet (degrades gracefully).
  • macOS native handler routes the new MethodChannel calls into Go.

End-to-end event flow

sequenceDiagram
    autonumber
    participant UI as SmC toggle
    participant FFI as lantern-core FFI
    participant Core as LanternCore
    participant Backend as radiance Backend
    participant Mode as peer/unbounded
    participant Bus as radiance events
    participant Globe as Flutter globe

    UI->>FFI: setPeerProxyEnabled(1) | setUnboundedEnabled(1)
    FFI->>Core: SetPeerShareEnabled | SetUnboundedEnabled
    Core->>Backend: PatchSettings(...)
    Note over Backend: applyPeerShare → peer.Client.Start<br/>or unbounded.shouldRun re-evaluates
    Backend->>Mode: Start
    loop each remote client
        Mode->>Bus: events.Emit(ConnectionEvent{...})
    end
    Bus->>Core: listenPeerConnectionEvents (subscribes to BOTH)
    Core->>FFI: notifyFlutter(EventTypePeerConnection, json)
    FFI->>Globe: dart_api_dl.SendToPort(appEventPort, ...)
    Globe->>Globe: appEvents stream → arc + heart-burst renders
Loading

How this was sliced

Cherry-picked from the original fisk/share-my-connection-ux (#8740) — the chronological first 21 commits. The second half (Unbounded as a top-level home tab + auto-enable + final polish) lands as the stacked follow-up.

Test plan

  • flutter analyze clean.
  • go build ./lantern-core/... clean.
  • macOS end-to-end (requires make macos-release after merging the dependent radiance PRs):
    • Settings → VPN → Share my connection.
    • Toggle on → take "Full mode (SmC)" → real peer.Client.Start runs (UPnP map → lantern-cloud register → verify → samizdat inbound).
    • Toggle on → take "Basic mode (Unbounded)" → broflake widget-proxy runs.
    • Toggle off, on, on/off cycle without leaks.
    • Advanced → set manual port = 5698 → toggle on → port forwarder uses manual mapping, skips UPnP probe.

Dependencies

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Share My Connection, including Unbounded mode and peer connection sharing.
    • Added connection status, activity counts, peer arrival notifications, and globe visualizations.
    • Added manual port configuration, validation, UPnP probing, and fallback behavior.
    • Added platform support for enabling, disabling, and checking sharing settings.
  • Bug Fixes
    • Improved error handling and reliability when configuring connections or communicating across platforms.
  • Style
    • Added new animations and localized interface text for sharing workflows.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Part 1/2 of the unified Share My Connection UX. Adds a dedicated screen with a rotating globe, a one-time disclosure dialog gating Full mode (samizdat-over-UPnP / SmC) vs Basic mode (Unbounded / broflake), and an Advanced section for a manual port-forward override. Wires four new FFI/MethodChannel surfaces (setPeerProxyEnabled, setPeerManualPort, setUnboundedEnabled, plus is* getters) through the Go FFI/mobile layers, the Dart service layers, macOS Swift handler, and a new radianceSettingsProvider.peerProxy field. Introduces a unified EventTypePeerConnection/EventTypePeerStatus FlutterEvent stream so the globe is protocol-agnostic, and sanitizes Go errors crossing gomobile to prevent invalid-UTF-8 crashes.

Changes:

  • New ShareMyConnectionScreen with globe, status card, Advanced manual-port field, SmC disclosure dialog, and Lottie heart-burst arrival toast.
  • New Go Core.{SetPeerShareEnabled, SetPeerManualPort, SetUnboundedEnabled, …} + FFI exports + mobile bindings + macOS MethodChannel handlers; listenPeerConnectionEvents/listenPeerStatusEvents bridge radiance events to Flutter.
  • RadianceSettingsState.peerProxy, RadianceSettings.setPeerProxy, gomobile sanitizeForGomobile for UTF-8/empty error normalization, new GeoLookupService, UnboundedConnectionEvent model, flutter_earth_globe/lottie/http deps, Lottie explosion + locale strings.

Reviewed changes

Copilot reviewed 20 out of 24 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
lib/features/share_my_connection/share_my_connection.dart New unified SmC screen with globe, disclosure, Advanced port field, arrival toast.
lib/features/setting/vpn_setting.dart Replaces inline SmC toggle with navigation tile to new screen (desktop only).
lib/features/home/provider/radiance_settings_providers.dart Adds peerProxy field/setter to settings provider; conditional load by platform.
lib/core/models/radiance_settings_state.dart Adds peerProxy to state, copyWith, equality, hashCode.
lib/core/models/unbounded_connection_event.dart New model representing peer connection events with geo fields.
lib/core/services/geo_lookup_service.dart New geo lookup service (self via geo.getiantem.org, peers via ipwho.is).
lib/lantern/lantern_core_service.dart Adds peer-proxy / manual-port / unbounded methods to interface.
lib/lantern/lantern_ffi_service.dart FFI implementations for the six new methods.
lib/lantern/lantern_platform_service.dart MethodChannel implementations for the six new methods.
lib/lantern/lantern_service.dart Routes new methods to FFI or platform service per platform.
lib/lantern/lantern_generated_bindings.dart Hand-added native bindings for four new C exports.
macos/Runner/Handlers/MethodHandler.swift macOS routing of new MethodChannel calls into Mobile* Go binds.
lantern-core/core.go New PeerShare interface, settings get/set, event bridges for peer/unbounded.
lantern-core/ffi/ffi.go New //export C functions for peer / unbounded / manual-port settings.
lantern-core/mobile/mobile.go gomobile-bound wrappers around the new Core methods.
lantern-core/utils/gostack.go Sanitizes errors for gomobile to ensure non-empty valid UTF-8 messages.
pubspec.yaml / pubspec.lock Adds flutter_earth_globe, lottie, http deps and assets/unbounded/.
go.mod / go.sum Bumps radiance and related deps; drops local replace directives.
assets/locales/en.po Adds share_my_connection / share_my_connection_subtitle strings.
assets/unbounded/explosion.json Lottie animation asset for arrival toast burst.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/features/share_my_connection/share_my_connection.dart
Comment thread lib/features/share_my_connection/share_my_connection.dart Outdated
Comment thread lib/features/share_my_connection/share_my_connection.dart Outdated
Comment thread lib/core/services/geo_lookup_service.dart
Comment thread lib/features/home/provider/radiance_settings_providers.dart Outdated
Comment thread lib/features/share_my_connection/share_my_connection.dart
Comment thread lantern-core/core.go
Adam Fisk and others added 21 commits May 29, 2026 13:58
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…sure

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…endpoint

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) <noreply@anthropic.com>
FlutterEvent bridge; wire SmC toggle to the real radiance peer module.

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) <noreply@anthropic.com>
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: <port>) 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…nnel

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) <noreply@anthropic.com>
…robe

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) <noreply@anthropic.com>
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=<bytes>"
      → 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(<bytes>)
      → [[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) <noreply@anthropic.com>
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/<TBD>).

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) <noreply@anthropic.com>
…adcrumb

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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ubscribe

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) <noreply@anthropic.com>
- Geo: peerLookup switched from geo.getiantem.org/<ip> (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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@myleshorton
myleshorton force-pushed the fisk/smc-unified-screen branch from f7e65ef to a190859 Compare May 29, 2026 19:58
myleshorton and others added 2 commits May 29, 2026 13:59
… replaces

After both stacks were rebased today, repin to fresh pseudo-versions:
- github.com/getlantern/radiance @ 3684cef (radiance #501 tip; has peer/,
  settings.PeerShareEnabledKey, unbounded/)
- github.com/getlantern/lantern-box @ 0b63c0f (lantern-box #255 tip;
  has tracker/peerconn + newer samizdat)

Removed the dev-only `replace ../radiance` and `replace ../lantern-box`
directives so this PR builds standalone for CI / reviewers. Once both
feature stacks land on their respective mains, this commit can be
amended away in favor of the released versions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
share_my_connection.dart:
- ShareState.copyWith now uses a sentinel default for errorMessage
  (Object? = _unsetErrorMessage) so callers can distinguish 'leave
  alone' from 'clear it'. The naive '?? this.errorMessage' pattern
  conflated the two and left stale error text wedged in state — the
  next phase transition into error would re-render the wrong message.
- _smcAck (the SmC disclosure ack) now persists via LocalStorageService
  using a containsKey-based 'smc_disclosure_acked' key, so the
  disclosure modal doesn't re-fire on every app restart.
- All new user-facing strings (~30) moved from hardcoded English
  into assets/locales/en.po and consumed via .i18n / .fill. Covers
  hero copy, status phase labels, status card stats, tooltip,
  arrival toast, Advanced section, manual port forward field,
  snackbar messages, and the disclosure dialog. Matches the
  established convention in vpn_setting.dart.

lib/core/services/geo_lookup_service.dart:
- Added a privacy note on peerLookup documenting the ipwho.is data
  flow: each call ships a peer's IP (typically a censored user's
  address) to a third-party geo-IP service. Documents the current
  rationale + the fix to do before any production-scale rollout
  (Lantern-controlled endpoint or local DB). The lookup itself
  stays; see PR reply for the design discussion.

lib/features/home/provider/radiance_settings_providers.dart:
- _refresh's fragile positional 'peerIdx' index into Future.wait
  results replaced with named-future await-per-variable. Adding
  another optional fetch later can't silently desync read indices.
  Performance unchanged: the futures are still started before any
  await, so they run concurrently; the awaits just collect them
  in order.

lantern-core/core.go:
- listenPeerConnectionEvents: unbounded.ConnectionEvent
  subscription was leaked (Subscribe but never Unsubscribe).
  Now captures the Subscription handle and unsubscribes on
  ctx.Done in a small companion goroutine.
- Dropped the redundant inner 'go func()' that wrapped the SSE
  call. The caller already spawned the outer goroutine via
  'go lc.listenPeerConnectionEvents()', so the inner go just
  exited the outer immediately and lost structured cancellation.
  The SSE call now blocks the outer goroutine directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 26 changed files in this pull request and generated 4 comments.

Comment thread lib/features/share_my_connection/share_my_connection.dart Outdated
Comment thread lib/features/share_my_connection/share_my_connection.dart
Comment thread lib/features/share_my_connection/share_my_connection.dart
Comment thread lantern-core/core.go Outdated
Four Copilot findings:

1. replayCurrentPeers fired before _initOrigin completed — replayed
   arcs drew to GlobeCoordinates(0,0) and never got corrected.
   Moved the replay call into _initOrigin's continuation so it
   runs only AFTER origin coords are known.

2. _addPeer fell back to GlobeCoordinates(0,0) when _originCoords
   hadn't loaded yet, so real-time +1 events arriving during the
   origin-lookup window could still draw to (0,0). Added a null
   guard: if origin isn't resolved, skip the draw — the peer is
   still tracked in the notifier's _peerArcs map (source of
   truth), and replayCurrentPeers in _initOrigin's continuation
   picks it up.

   Reordered initState: subscribe FIRST so real-time events
   accumulate in _peerArcs while origin is loading, then call
   _initOrigin which finishes by calling replayCurrentPeers.
   With both changes there's no window where a peer is drawn
   without correct origin coords.

3. debugPrint comment claimed it 'avoids bringing in the
   appLogger' and that 'real impl can switch to slog' — but the
   file already uses appLogger, and slog isn't a Dart logger.
   Rewrote to describe the actual rationale: avoid escalating a
   single malformed wire event to a user-visible error toast in
   debug builds; keep the listener subscribed so subsequent
   well-formed events still arrive.

4. lantern-core's EventTypePeerConnection doc described it as
   samizdat-only with a {state, source} payload. Both donor
   protocols now emit on this event type with the unified
   {state, source, timestamp} payload (peer-share's marshal was
   bumped in the previous round to include timestamp). Updated
   the doc accordingly with source-format details for both
   protocols.

dart analyze clean; Go build clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 26 changed files in this pull request and generated 6 comments.

Comment thread lantern-core/core.go Outdated
Comment thread ios/Runner/Handlers/MethodHandler.swift Outdated
Comment thread macos/Runner/Handlers/MethodHandler.swift
Comment thread macos/Runner/Handlers/MethodHandler.swift
Comment thread ios/Runner/Handlers/MethodHandler.swift
Six Copilot findings:

1. lantern-core/core.go's listenPeerConnectionEvents subscribed
   to unbounded.ConnectionEvent and started a goroutine waiting
   on ctx.Done to unsubscribe. If client.PeerConnectionEvents
   returned an error while ctx was still live, the function
   returned but the ctx-watcher goroutine + subscription leaked
   for the rest of the process. Replaced with 'defer
   unbSub.Unsubscribe()' so cleanup runs on both exit paths
   (normal ctx cancel + unexpected stream exit).

2. iOS handler comment said the SmC setter helpers use a
   'detached Task' but the implementation uses 'Task {}'. Updated
   the comment to describe the actual choice — plain Task is
   fine for the millisecond-range PatchSettings calls because
   inheriting the current actor's executor is cheap; the
   probeUPnP case is the one exception that uses Task.detached
   because its M-SEARCH wait is multi-second.

3-5. macOS / iOS handlers had unsafe defaults on the SmC setters:
     - setPeerProxyEnabled: defaulted enabled=false on missing arg
       (would silently disable sharing on caller bugs)
     - setPeerManualPort: defaulted port=0 on missing arg (would
       silently clear the user's manual port override, since 0
       has the real semantic of 'no manual port')
     - setUnboundedEnabled: same Bool-defaults-to-false issue
       (Copilot didn't flag this one but it has the identical
       problem)
     All four now route through requireArg, which surfaces a
     FlutterError on missing/invalid argument shape instead of
     defaulting silently.

6. Android setPeerManualPort defaulted port=0 the same way.
   Switched the elvis '?: 0' to '?: error("Missing port")' to
   match the SetPeerProxyEnabled pattern on Android.

Go build clean. The Swift / Kotlin changes are mechanical and
follow patterns already established in the same files
(requireArg / error()-on-missing).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 26 changed files in this pull request and generated 3 comments.

Comment thread lib/features/share_my_connection/share_my_connection.dart
Comment thread assets/locales/en.po
Comment on lines +223 to +252
Future<void> toggle(BuildContext context, WidgetRef widgetRef) async {
if (state.active || state.probing) {
await _stop(widgetRef);
return;
}

state = state.copyWith(probing: true);

// 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;
}

// Real UPnP probe via FFI / MethodChannel. probeUPnP runs IGD
// discovery on the local network and returns true when a usable
// gateway is reachable. Blocks up to ~6 seconds on the M-SEARCH
// multicast wait — long enough that the "Probing your network…"
// status from copyWith(probing: true) above is visible to the
// user. Any failure (no IGD, timeout, FFI / channel error) is
// treated as "UPnP unavailable" → fall back to Unbounded.
final probeRes =
await widgetRef.read(lanternServiceProvider).probeUPnP();
final upnpAvailable = probeRes.fold((_) => false, (v) => v);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back: tests would be valuable here, but I'd rather add them after #8820 (Part 2/2) lands than now. #8820 substantially rewrites ShareNotifier.toggle's mode-selection (auto-enable path becomes Unbounded-only, the screen becomes a tab embed rather than a navigated screen, the welcome dialog and Unbounded settings get introduced, _fallbackToUnbounded lands). Tests written against the #8819-only surface would need to be largely rewritten 24 hours later.

Tracking this as a follow-up task to land alongside #8820_extractIP is the cleanest immediate test target since its surface is fixed; toggle and _handlePeerStatus should wait until the Part 2 lifecycle is in place.

Two Copilot fixes (+ a third pushback in the reply):

1. _resolveAndEmit awaits peerLookup. If the notifier is disposed
   during the await, _eventController.close() has already run; the
   subsequent _eventController.add would throw 'Bad state: Cannot
   add event after closing'. Added an isClosed check after the
   await before the identity check.

2. smc_stat_total_today msgstr read 'Total today' but the
   ShareState.totalCount is session-scoped (reset on every
   toggle-on, no day bucket, no persistence). Renamed to 'Total
   this session' so the label matches the implemented semantics.
   #8820's rebase later replaces this key with smc_stat_total_helped
   + 'Total people helped to date' alongside persistence via
   unboundedTotalHelped — until then the more honest 'session'
   wording matches reality.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 26 changed files in this pull request and generated 4 comments.

Comment thread lib/features/share_my_connection/share_my_connection.dart
Comment thread lib/features/setting/vpn_setting.dart
Comment thread lantern-core/core.go Outdated
Comment thread lib/core/services/geo_lookup_service.dart
myleshorton and others added 2 commits June 1, 2026 15:08
Two new Copilot fixes (+ a third escalation acknowledged in reply):

1. shareProvider was the default (non-annotated) NotifierProvider,
   which is autoDispose in Riverpod 3.x — so navigating away from
   the screen disposed the notifier, re-entry reset state to
   mode=off / active=false even when SmC or Unbounded was still
   running, and the next toggle tried to re-enable an already-
   enabled setting. Added `ref.keepAlive()` at the top of
   build() so the notifier sticks for the process lifetime.
   onDispose stays registered for the explicit teardown paths
   (provider container reset, hot reload) so the event
   subscription + stream controller still get cleaned up.

2. VPN settings tile rendered "Off" when the user had picked
   "Basic mode (Unbounded)" in the disclosure dialog because
   the subtitle was driven solely by peerProxy. Added
   unboundedEnabled to RadianceSettingsState (with a copyWith
   field + equality / hashCode update), wired the fetch +
   setter through radianceSettingsProvider, and the tile now
   reads OR of both to decide whether to show
   share_my_connection_on_tap_to_view.

dart analyze clean on the touched files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per Copilot — the previous comment claimed parity with the
SSE-stream-failure path, but in practice PeerConnectionEvents
blocks until ctx cancellation and there's no retry loop wrapping
listenPeerConnectionEvents, so the defer effectively runs at
process shutdown. Rewrote to spell that out while still calling
out why defer is the right shape (future-proofing against early
returns / retry wrappers).

No code change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
myleshorton added a commit that referenced this pull request Jun 2, 2026
Addresses Copilot review on #8820 (commit 74f58f0):

- Preserve totalCount when radiance reports SmC phase=idle. The
  rebase combined #8819's idle-handling with #8820's lifetime
  totalCount semantics; the literal const ShareState() reset on
  idle clobbered totalCount until the next app restart re-seeded
  it from disk. Now constructs ShareState with totalCount carried
  over, matching the pattern in _stop.
- Guard _ArrivalLottie.onLoaded against mounted=false and dispose
  any prior AnimationController before reassigning. Lottie can
  fire onLoaded after dispose if the surrounding _ArrivalCard
  rebuilds rapidly mid-load. Mirrors the fix already on
  _BurstAnimation.
- Localize "Waiting for connections..." pill via new
  unbounded_waiting_for_connections key.
- Localize Welcome dialog title and 3 body paragraphs via
  unbounded_welcome_title / unbounded_welcome_body_{1,2,3}.
- Localize the Got It button via existing got_it key. Remove the
  Learn More button — its onPressed was a no-op TODO and a dead
  control reads worse than no control. Will be reintroduced
  pointing at AppUrls.unbounded once that URL exists.
- Localize Home tab labels VPN / Unbounded via new vpn key plus
  existing unbounded key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@teamlantern

Copy link
Copy Markdown

This PR has been inactive for 30 days.

Waiting on: none

It will be closed automatically in 7 days if no action is taken.

@teamlantern

Copy link
Copy Markdown

This PR has been inactive for 31 days.

Waiting on: none

It will be closed automatically in 7 days if no action is taken.

@teamlantern teamlantern removed the stale label Aug 4, 2026
@myleshorton myleshorton mentioned this pull request Aug 5, 2026
The branch was 88 commits behind main. Three conflicts:

- pubspec.yaml did not conflict textually, but main had independently
  added `http: ^1.2.0` to the same dependencies block that this branch
  added `http: ^1.2.2` to. Dart's yaml parser rejects duplicate mapping
  keys (PyYAML silently keeps the last), which is what broke
  `flutter pub get` with Error 255. Dropped the duplicate; main's
  `^1.2.0` resolves to 1.6.0 in pubspec.lock, satisfying both.

- lantern_generated_bindings.dart: kept main's clearTunnelCache binding,
  which this branch predates.

- go.mod/go.sum: took main's newer versions and re-pinned radiance to the
  smc/unbounded integration tip for the peer + unbounded APIs. Re-pin to a
  released radiance once radiance #589 and #590 merge.

Regenerated .g.dart provider hashes via `dart run build_runner build`.
Copilot AI review requested due to automatic review settings August 5, 2026 21:28
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds Share My Connection and Unbounded controls across LanternCore, native bridges, Dart services, and Flutter UI. It adds peer event forwarding, UPnP probing, manual-port configuration, geo lookup, connection visualization, localization, and animation assets.

Changes

Share My Connection

Layer / File(s) Summary
Core peer contracts and events
lantern-core/core.go
LanternCore exposes peer-sharing settings, manual-port validation, UPnP probing, Unbounded controls, and peer connection/status events.
Native mobile and platform bridge
lantern-core/mobile/mobile.go, lantern-core/ffi/ffi.go, android/..., ios/..., macos/..., lantern-core/utils/gostack.go
Native APIs and method channels support peer proxying, manual ports, Unbounded mode, and UPnP probing. Gomobile errors are normalized before crossing the bridge.
Dart service API wiring
lib/lantern/*
Dart services route the new operations through FFI or MethodChannel implementations and convert results to typed failures or values.
Flutter state and connection data
lib/core/models/*, lib/core/services/geo_lookup_service.dart, lib/features/home/provider/*, lib/features/setting/vpn_setting.dart
Settings, Unbounded events, geo results, provider state, and VPN settings now represent Share My Connection state.
Share My Connection flow and presentation
lib/features/share_my_connection/*, assets/locales/en.po, assets/unbounded/*, pubspec.yaml, go.mod
The new screen manages sharing modes, lifecycle states, peer visualization, notifications, manual-port settings, disclosure content, localization, animation assets, and dependency updates.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ShareMyConnectionScreen
  participant ShareNotifier
  participant LanternService
  participant LanternCore
  participant PeerEvents
  ShareMyConnectionScreen->>ShareNotifier: enable sharing
  ShareNotifier->>LanternService: probeUPnP and set sharing mode
  LanternService->>LanternCore: invoke peer-sharing or Unbounded API
  PeerEvents->>LanternCore: deliver connection and status events
  LanternCore->>ShareNotifier: emit peer event payloads
  ShareNotifier->>ShareMyConnectionScreen: update counts, globe, and notifications
Loading

Possibly related issues

  • getlantern/engineering#3221 — Covers the Flutter/native Share My Connection toggle, UPnP probing, manual ports, and peer-proxy lifecycle implemented here.

Possibly related PRs

  • getlantern/lantern#8729 — Adds the related peer-share toggle wiring in the same core, FFI, and mobile API layers.

Suggested reviewers: copilot, garmr-ulfr

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the unified Share My Connection screen and its main globe, Advanced section, and Unbounded Basic mode changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fisk/smc-unified-screen

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 29 changed files in this pull request and generated no new comments.

Files not reviewed (3)
  • lib/features/home/provider/home_notifier.g.dart: Generated file
  • lib/features/home/provider/radiance_settings_providers.g.dart: Generated file
  • lib/features/vpn/provider/available_servers_notifier.g.dart: Generated file
Suppressed comments (4)

lib/features/share_my_connection/share_my_connection.dart:221

  • ShareNotifier always initializes to const ShareState() and never reconciles with persisted backend settings (peer proxy / Unbounded). After an app restart (or if the setting is enabled outside this screen), this screen can render as Off and the toggle path will try to enable again instead of allowing the user to disable an already-running share session.

Consider bootstrapping state from LanternCoreService.isPeerProxyEnabled() / isUnboundedEnabled() (or the already-refreshed radianceSettingsProvider state) and starting the app-event subscription when either is already enabled so the UI reflects actual runtime state.

    ref.onDispose(() {
      _stopEventSubscription();
      _eventController.close();
    });
    return const ShareState();

lib/core/services/geo_lookup_service.dart:206

  • peerLookup() sends peers' source IP addresses to ipwho.is (third-party). Even with caching, this is a new external data flow for potentially sensitive user IPs and can also become an availability risk if that endpoint is blocked/slow (5s timeout) in some regions.

Before shipping broadly, consider gating this behind an explicit user consent/feature flag, or proxying through a Lantern-controlled endpoint / local DB so peer IPs aren’t shared with a third party.

  /// Privacy note: each call ships [ip] — the address of a peer routing
  /// through this user's Share My Connection inbound — to ipwho.is, a
  /// third-party geo-IP service. For most SmC consumers these are
  /// censored users' addresses, so this is an outbound data flow to a
  /// non-Lantern endpoint. Current rationale for accepting it: the IP

lib/features/setting/vpn_setting.dart:158

  • This screen navigation uses Navigator.push(MaterialPageRoute(...)), but the rest of this settings screen consistently navigates via appRouter.push(...) (auto_route). Using a raw MaterialPageRoute here makes routing/deeplinks/analytics inconsistent and is harder to refactor later.

Prefer adding ShareMyConnection to the app router (@RoutePage + route entry) and pushing it via appRouter.push(...).

              onPressed: () {
                Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: (_) => const ShareMyConnectionScreen(),
                  ),

lib/features/share_my_connection/share_my_connection.dart:404

  • _stop() cancels the app-event subscription and resets UI state before verifying that the underlying disable call succeeded, and it also ignores the returned Either. If the IPC/MethodChannel call fails, the UI will show sharing as Off and the globe arcs are cleared even though the backend setting may still be enabled.
  Future<void> _stop(WidgetRef widgetRef) async {
    _stopEventSubscription();
    final priorMode = state.mode;
    state = const ShareState();
    switch (priorMode) {

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (6)
lib/features/setting/vpn_setting.dart (1)

154-160: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use appRouter for this navigation.

Every other tile in this file pushes through appRouter. This card uses Navigator.of(context).push with a raw MaterialPageRoute, so ShareMyConnectionScreen stays outside the AutoRoute graph and cannot be targeted by a route name or deep link. Register the screen as a route and push it through appRouter for consistency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/features/setting/vpn_setting.dart` around lines 154 - 160, Update the
navigation callback for ShareMyConnectionScreen to use the existing appRouter
instead of Navigator.of(context) with a raw MaterialPageRoute. Register
ShareMyConnectionScreen in the AutoRoute configuration and navigate to its
generated route so it remains addressable by route name and deep links, matching
the other tiles in this file.
lib/core/services/geo_lookup_service.dart (2)

44-47: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Bound the per-IP cache.

_peerCache grows for the process lifetime with one entry per distinct peer IP. A host that shares for a long period serves many distinct clients, so the map has no upper bound. Cap it with a small LRU or evict on _stopEventSubscription to keep memory predictable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/core/services/geo_lookup_service.dart` around lines 44 - 47, Bound the
lifetime and size of the static _peerCache used by GeoLookupService. Update the
cache access logic to enforce a small LRU limit, refreshing entries on reuse and
evicting the least-recently-used entries when the limit is exceeded; preserve
resetCacheForTest behavior and ensure any existing _stopEventSubscription
cleanup also clears the cache where appropriate.

222-224: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Encode the IP as a path segment.

ip reaches this line from _extractIP, which returns the input unchanged when it contains no colon. Any unexpected character then lands unencoded in the request path. Build the URI from path segments so the request shape stays fixed.

♻️ Proposed change
-      final response = await http
-          .get(Uri.parse('$_peerUrl/$ip'))
-          .timeout(const Duration(seconds: 5));
+      final response = await http
+          .get(Uri.parse(_peerUrl).replace(pathSegments: [ip]))
+          .timeout(const Duration(seconds: 5));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/core/services/geo_lookup_service.dart` around lines 222 - 224, Update the
request construction in the geo lookup method around _extractIP so the IP value
is encoded as a URI path segment rather than interpolated directly into the URL
string. Preserve the existing peer URL, IP, timeout, and HTTP GET behavior while
building the URI with path-segment APIs.
lib/features/share_my_connection/share_my_connection.dart (1)

418-423: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Cancel the previous subscription before assigning a new one.

_startEventSubscription overwrites _appEventSub without cancelling it. If _start runs twice without an intervening _stopEventSubscription, the old subscription stays active, every peer event is handled twice, and activeCount double-counts.

🛡️ Proposed guard
   void _startEventSubscription(WidgetRef widgetRef) {
+    _appEventSub?.cancel();
     _peerArcs.clear();
     _appEventSub = widgetRef
         .read(lanternServiceProvider)
         .watchAppEvents()
         .listen((event) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/features/share_my_connection/share_my_connection.dart` around lines 418 -
423, Update _startEventSubscription to cancel the existing _appEventSub before
creating and assigning the new watchAppEvents subscription, ensuring repeated
starts leave only one active listener and prevent duplicate peer-event handling.
assets/locales/en.po (1)

665-666: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename the key to match the text.

The key says total_today but the text says "Total this session". ShareState.totalCount resets on each toggle, so the text is correct and the key is misleading for translators. Rename the key to smc_stat_total_session and update the call site in lib/features/share_my_connection/share_my_connection.dart line 784.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@assets/locales/en.po` around lines 665 - 666, Rename the locale key from
smc_stat_total_today to smc_stat_total_session in the English translation and
update the corresponding lookup in ShareState’s call site within
share_my_connection.dart, preserving the existing “Total this session” text.
lib/core/models/unbounded_connection_event.dart (1)

42-48: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove unused UnboundedStats.

No Dart file references UnboundedStats; the active/count values live in ShareState, so remove this competing count model unless a consumer is added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/core/models/unbounded_connection_event.dart` around lines 42 - 48, Remove
the unused UnboundedStats class and its activeCount/totalCount fields from the
model file, since connection counts are maintained by ShareState and no
consumers reference this competing model.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lantern-core/ffi/ffi.go`:
- Around line 1423-1430: Update the int-returning exports that call requireCore—
including isPeerProxyEnabled, getPeerManualPort, probeUPnP, and
isUnboundedEnabled—so the unused error C string is freed whenever returned, or
switch these getter paths to the allocation-free loadCore helper. Preserve each
export’s existing return behavior.

In `@lib/features/setting/vpn_setting.dart`:
- Around line 73-94: Move the unconditional DividerSpace() following the routing
block into the !PlatformUtils.isIOS block in the settings widget, placing it
after SplitTunnelingTile’s routing tile so it only separates existing tiles.
Remove the standalone divider to prevent trailing dividers on iOS and Linux.

In `@lib/features/share_my_connection/share_my_connection.dart`:
- Around line 1358-1371: Update the TextField itself to bind its enabled state
to loaded.value && !saving.value, and remove the same enabled setting from
InputDecoration. Keep the existing label, hint, border, and input configuration
unchanged.

---

Nitpick comments:
In `@assets/locales/en.po`:
- Around line 665-666: Rename the locale key from smc_stat_total_today to
smc_stat_total_session in the English translation and update the corresponding
lookup in ShareState’s call site within share_my_connection.dart, preserving the
existing “Total this session” text.

In `@lib/core/models/unbounded_connection_event.dart`:
- Around line 42-48: Remove the unused UnboundedStats class and its
activeCount/totalCount fields from the model file, since connection counts are
maintained by ShareState and no consumers reference this competing model.

In `@lib/core/services/geo_lookup_service.dart`:
- Around line 44-47: Bound the lifetime and size of the static _peerCache used
by GeoLookupService. Update the cache access logic to enforce a small LRU limit,
refreshing entries on reuse and evicting the least-recently-used entries when
the limit is exceeded; preserve resetCacheForTest behavior and ensure any
existing _stopEventSubscription cleanup also clears the cache where appropriate.
- Around line 222-224: Update the request construction in the geo lookup method
around _extractIP so the IP value is encoded as a URI path segment rather than
interpolated directly into the URL string. Preserve the existing peer URL, IP,
timeout, and HTTP GET behavior while building the URI with path-segment APIs.

In `@lib/features/setting/vpn_setting.dart`:
- Around line 154-160: Update the navigation callback for
ShareMyConnectionScreen to use the existing appRouter instead of
Navigator.of(context) with a raw MaterialPageRoute. Register
ShareMyConnectionScreen in the AutoRoute configuration and navigate to its
generated route so it remains addressable by route name and deep links, matching
the other tiles in this file.

In `@lib/features/share_my_connection/share_my_connection.dart`:
- Around line 418-423: Update _startEventSubscription to cancel the existing
_appEventSub before creating and assigning the new watchAppEvents subscription,
ensuring repeated starts leave only one active listener and prevent duplicate
peer-event handling.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43260372-cb40-4689-97ef-56a9d265fded

📥 Commits

Reviewing files that changed from the base of the PR and between 8a9af4a and 8c9cdb3.

⛔ Files ignored due to path filters (4)
  • assets/unbounded/uv-map-dark.png is excluded by !**/*.png
  • assets/unbounded/uv-map.png is excluded by !**/*.png
  • go.sum is excluded by !**/*.sum
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (25)
  • android/app/src/main/kotlin/org/getlantern/lantern/handler/MethodHandler.kt
  • assets/locales/en.po
  • assets/unbounded/explosion.json
  • go.mod
  • ios/Runner/Handlers/MethodHandler.swift
  • lantern-core/core.go
  • lantern-core/ffi/ffi.go
  • lantern-core/mobile/mobile.go
  • lantern-core/utils/gostack.go
  • lib/core/models/radiance_settings_state.dart
  • lib/core/models/unbounded_connection_event.dart
  • lib/core/services/geo_lookup_service.dart
  • lib/features/home/provider/home_notifier.g.dart
  • lib/features/home/provider/radiance_settings_providers.dart
  • lib/features/home/provider/radiance_settings_providers.g.dart
  • lib/features/setting/vpn_setting.dart
  • lib/features/share_my_connection/share_my_connection.dart
  • lib/features/vpn/provider/available_servers_notifier.g.dart
  • lib/lantern/lantern_core_service.dart
  • lib/lantern/lantern_ffi_service.dart
  • lib/lantern/lantern_generated_bindings.dart
  • lib/lantern/lantern_platform_service.dart
  • lib/lantern/lantern_service.dart
  • macos/Runner/Handlers/MethodHandler.swift
  • pubspec.yaml

Comment thread lantern-core/ffi/ffi.go
Comment on lines +1423 to +1430
//export isPeerProxyEnabled
func isPeerProxyEnabled() C.int {
c, _ := requireCore()
if c != nil && c.IsPeerShareEnabled() {
return 1
}
return 0
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find every export that discards the requireCore error string.
set -euo pipefail

rg -n -B 3 -A 3 'requireCore\(\)' lantern-core/ffi/ffi.go
rg -n -C 4 'func freeCString' lantern-core/ffi/ffi.go

Repository: getlantern/lantern

Length of output: 17201


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ffi.go requireCore definition and callers == "
sed -n '55,70p;1418,1511p' lantern-core/ffi/ffi.go

echo "== Dart uses of requireCore caller-like symbols? =="
rg -n "isPeerProxyEnabled|getPeerManualPort|probeUPnP|isUnboundedEnabled|isTelemetryEnabled|isOAuthLogin|isSplitTunnelingEnabled|isVPNConnected|isBlockAdsEnabled|isSmartRoutingEnabled" .

Repository: getlantern/lantern

Length of output: 29050


Free the unused error string returned by requireCore.

requireCore allocates a C string when the core is unavailable. Int-returning exports discard that pointer with c, _ := requireCore(), including isPeerProxyEnabled, getPeerManualPort, probeUPnP, and isUnboundedEnabled. Free the pointer, or use an allocation-free loadCore() helper for these getters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lantern-core/ffi/ffi.go` around lines 1423 - 1430, Update the int-returning
exports that call requireCore— including isPeerProxyEnabled, getPeerManualPort,
probeUPnP, and isUnboundedEnabled—so the unused error C string is freed whenever
returned, or switch these getter paths to the allocation-free loadCore helper.
Preserve each export’s existing return behavior.

Comment on lines +73 to +94
if (!PlatformUtils.isIOS) ...[
DividerSpace(),
SplitTunnelingTile(
label: 'routing_mode'.i18n,
icon: AppImagePaths.route,
actionText: routingMode.label(),
onPressed: () => appRouter.push(const SmartRouting()),
)
},
),
],
DividerSpace(),
if (PlatformUtils.isAndroid ||
PlatformUtils.isMacOS ||
PlatformUtils.isWindows) ...{
PlatformUtils.isWindows) ...[
SplitTunnelingTile(
label: 'split_tunneling'.i18n,
icon: AppImagePaths.callSpilt,
actionText:
splitTunnelingEnabled ? 'enabled'.i18n : 'disabled'.i18n,
onPressed: () => appRouter.push(const SplitTunneling()),
),
DividerSpace()
},
DividerSpace(),
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A trailing divider renders on iOS and Linux.

DividerSpace() at line 82 is now unconditional, but both surrounding blocks are platform-gated.

  • On iOS the routing block is skipped and the split-tunneling block is skipped, so the card ends with server_locations followed by a divider.
  • On Linux the split-tunneling condition excludes the platform, so the card ends with the routing tile followed by a divider.

Move the divider into the leading block so it only renders between tiles.

🐛 Proposed fix
               if (!PlatformUtils.isIOS) ...[
                 DividerSpace(),
                 SplitTunnelingTile(
                   label: 'routing_mode'.i18n,
                   icon: AppImagePaths.route,
                   actionText: routingMode.label(),
                   onPressed: () => appRouter.push(const SmartRouting()),
                 ),
               ],
-              DividerSpace(),
               if (PlatformUtils.isAndroid ||
                   PlatformUtils.isMacOS ||
                   PlatformUtils.isWindows) ...[
+                DividerSpace(),
                 SplitTunnelingTile(
                   label: 'split_tunneling'.i18n,
                   icon: AppImagePaths.callSpilt,
                   actionText:
                       splitTunnelingEnabled ? 'enabled'.i18n : 'disabled'.i18n,
                   onPressed: () => appRouter.push(const SplitTunneling()),
                 ),
-                DividerSpace(),
               ],
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!PlatformUtils.isIOS) ...[
DividerSpace(),
SplitTunnelingTile(
label: 'routing_mode'.i18n,
icon: AppImagePaths.route,
actionText: routingMode.label(),
onPressed: () => appRouter.push(const SmartRouting()),
)
},
),
],
DividerSpace(),
if (PlatformUtils.isAndroid ||
PlatformUtils.isMacOS ||
PlatformUtils.isWindows) ...{
PlatformUtils.isWindows) ...[
SplitTunnelingTile(
label: 'split_tunneling'.i18n,
icon: AppImagePaths.callSpilt,
actionText:
splitTunnelingEnabled ? 'enabled'.i18n : 'disabled'.i18n,
onPressed: () => appRouter.push(const SplitTunneling()),
),
DividerSpace()
},
DividerSpace(),
],
if (!PlatformUtils.isIOS) ...[
DividerSpace(),
SplitTunnelingTile(
label: 'routing_mode'.i18n,
icon: AppImagePaths.route,
actionText: routingMode.label(),
onPressed: () => appRouter.push(const SmartRouting()),
),
],
if (PlatformUtils.isAndroid ||
PlatformUtils.isMacOS ||
PlatformUtils.isWindows) ...[
DividerSpace(),
SplitTunnelingTile(
label: 'split_tunneling'.i18n,
icon: AppImagePaths.callSpilt,
actionText:
splitTunnelingEnabled ? 'enabled'.i18n : 'disabled'.i18n,
onPressed: () => appRouter.push(const SplitTunneling()),
),
],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/features/setting/vpn_setting.dart` around lines 73 - 94, Move the
unconditional DividerSpace() following the routing block into the
!PlatformUtils.isIOS block in the settings widget, placing it after
SplitTunnelingTile’s routing tile so it only separates existing tiles. Remove
the standalone divider to prevent trailing dividers on iOS and Linux.

Comment on lines +1358 to +1371
child: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(
decimal: false,
signed: false,
),
decoration: InputDecoration(
labelText: 'smc_manual_port_label'.i18n,
hintText: 'smc_manual_port_hint'.i18n,
border: const OutlineInputBorder(),
isDense: true,
enabled: loaded.value && !saving.value,
),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Flutter InputDecoration enabled property versus TextField enabled property difference

💡 Result:

In Flutter, the enabled property on a TextField (or TextFormField) and the enabled property on an InputDecoration serve different purposes, with the TextField's property acting as the primary control for interaction [1][2]. 1. TextField.enabled: This property controls the overall interactivity of the text field [1]. When set to false, the text field is "disabled": it ignores all user input (taps, typing, etc.) [1][2]. Additionally, it overrides the enabled property found within the InputDecoration [1]. When a TextField is disabled, its entire UI, including its decoration and children like the suffixIcon, will appear disabled (usually rendered in grey or with reduced opacity) and become non-interactive [1][3]. 2. InputDecoration.enabled: This property is purely visual and defines the state of the decoration itself [4][5]. When set to false, it reduces the opacity of visual elements like the helperText, errorText, and counterText [4]. It does not disable user interaction with the text field itself [1]. In practice, because TextField.enabled overrides this property when provided, the InputDecoration.enabled property is primarily managed automatically by the parent TextField based on its own state [1]. Key Distinction: If you want to make a text field non-editable but keep its contents (like a suffix icon) interactive, setting TextField.enabled to false will not work because it disables the entire widget [1][3]. Instead, you should keep the TextField enabled (set to true) and use the readOnly property combined with enableInteractiveSelection: false [1][3]. This allows the field to appear as if it is in a read-only state while ensuring that buttons within the decoration remain clickable [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="lib/features/share_my_connection/share_my_connection.dart"

echo "== file exists and line count =="
test -f "$file" && wc -l "$file"

echo "== relevant TextField section (1348-1380) =="
sed -n '1348,1380p' "$file" | cat -n

echo "== relevant context lines around 1320-1344 =="
sed -n '1320,1344p' "$file" | cat -n

echo "== controller and onChanged usages =="
rg -n "controller:|onChanged|loaded\.value|saving\.value|load|load.*port|port.*load" "$file" | sed -n '1,120p'

echo "== Flutter docs excerpts if local cache/dependency is unavailable check package api =="
python3 - <<'PY'
from pathlib import Path
candidates=[]
for p in Path('.').rglob('text_field.dart'):
    if 'flutter/packages/flutter/lib/src/material/text_field.dart' in str(p):
        candidates.append(str(p))
print("local flutter text_field.dart:", candidates[:3])
PY

Repository: getlantern/lantern

Length of output: 4268


🌐 Web query:

Flutter TextEditingController text assignment listener called isSettable

💡 Result:

In Flutter, when you assign a new string to the text property of a TextEditingController, it triggers all registered listeners [1]. There is no property or parameter named isSettable in the TextEditingController class; the term is likely a misunderstanding or confusion with the isSetter property found in other parts of the Dart language (such as in the Invocation or reflection libraries) [2][3][4]. Key technical points regarding TextEditingController text assignment: 1. Listener Notification: Setting the text property updates the controller's value and calls notifyListeners [1]. This ensures that any component observing the controller (via addListener) is notified of the change [5][6]. 2. Distinction from onChanged: Note that assigning a value to controller.text does not trigger the TextField's own onChanged callback [7][8]. The onChanged callback is specifically designed to report user-initiated changes (e.g., typing via the keyboard) [7][8]. Programmatic updates via the controller are intended to be managed by the developer directly [8]. 3. Best Practices: - Setting the text property causes listeners to fire synchronously [9]. You should avoid setting the text property during the build, layout, or paint phases, as this can lead to errors or performance issues [1]. - If you need to perform actions when the text changes—whether initiated by the user or programmatically—the recommended approach is to add a listener to the controller using addListener [6][8]. There is no native framework mechanism or configuration property called isSettable that controls whether listeners are called during text assignment [1][5]. Any time the text value is updated, the associated listeners will be notified [1][10].

Citations:


Disable the TextField instead of only its decoration.

InputDecoration.enabled changes styling; it does not prevent typing. When loaded.value is false or saving.value is true, set TextField.enabled so user input cannot change the controller value that may later be overwritten by the persisted port load.

🐛 Proposed fix
               child: TextField(
                 controller: controller,
+                enabled: loaded.value && !saving.value,
                 keyboardType: const TextInputType.numberWithOptions(
                   decimal: false,
                   signed: false,
                 ),
                 decoration: InputDecoration(
                   labelText: 'smc_manual_port_label'.i18n,
                   hintText: 'smc_manual_port_hint'.i18n,
                   border: const OutlineInputBorder(),
                   isDense: true,
-                  enabled: loaded.value && !saving.value,
                 ),
               ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
child: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(
decimal: false,
signed: false,
),
decoration: InputDecoration(
labelText: 'smc_manual_port_label'.i18n,
hintText: 'smc_manual_port_hint'.i18n,
border: const OutlineInputBorder(),
isDense: true,
enabled: loaded.value && !saving.value,
),
),
child: TextField(
controller: controller,
enabled: loaded.value && !saving.value,
keyboardType: const TextInputType.numberWithOptions(
decimal: false,
signed: false,
),
decoration: InputDecoration(
labelText: 'smc_manual_port_label'.i18n,
hintText: 'smc_manual_port_hint'.i18n,
border: const OutlineInputBorder(),
isDense: true,
),
),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/features/share_my_connection/share_my_connection.dart` around lines 1358
- 1371, Update the TextField itself to bind its enabled state to loaded.value &&
!saving.value, and remove the same enabled setting from InputDecoration. Keep
the existing label, hint, border, and input configuration unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants