@@ -35,20 +35,26 @@ import {
3535} from '@functionland/component-library' ;
3636import { useTranslation } from 'react-i18next' ;
3737import NetInfo from '@react-native-community/netinfo' ;
38+ import AsyncStorage from '@react-native-async-storage/async-storage' ;
3839
3940import { usePluginsStore } from '../../stores/usePluginsStore' ;
4041import { Routes } from '../../navigation/navigationConfig' ;
42+ import * as Constants from '../../utils/constants' ;
4143
4244// Generic captive-portal-style "is the phone online" probe. 204 No Content
4345// is the standard "internet works" signal — chosen over a Fula-specific URL
4446// because this card is about the PHONE's connectivity, not the device's
4547// (per Codex review: don't conflate the two).
4648const PHONE_INTERNET_PROBE_URL = 'https://www.google.com/generate_204' ;
4749const PHONE_INTERNET_PROBE_TIMEOUT_MS = 5000 ;
50+ const DISCOVERY_PROBE_URL = `${ Constants . FXDiscoveryURL } /relays` ;
51+ const DISCOVERY_PROBE_TIMEOUT_MS = 5000 ;
52+ const RELAY_PROBE_TIMEOUT_MS = 5000 ;
4853const BLOX_AI_PLUGIN_NAME = 'blox-ai' ;
4954
5055type ProbeStatus = 'checking' | 'ok' | 'failed' ;
5156type PluginPresence = 'checking' | 'installed' | 'notInstalledOrUnavailable' ;
57+ type RelayInfo = { dnsName : string ; status : ProbeStatus } ;
5258
5359async function probePhoneInternet ( ) : Promise < ProbeStatus > {
5460 try {
@@ -71,13 +77,127 @@ async function probePhoneInternet(): Promise<ProbeStatus> {
7177 }
7278}
7379
80+ // Probe the Fula discovery API and return the relay list. Falls back to the
81+ // AsyncStorage cache when the live API is unreachable so we can still show
82+ // per-relay status from the last-known list. Final fallback is the hardcoded
83+ // FXRelay constant (parsed from its multiaddr) so the user always sees at
84+ // least one relay to probe — matches the same resolution order helper.ts
85+ // uses for actual connections.
86+ async function probeDiscoveryAndListRelays ( ) : Promise < {
87+ discovery : ProbeStatus ;
88+ dnsNames : string [ ] ;
89+ } > {
90+ let discovery : ProbeStatus = 'failed' ;
91+ let liveDnsNames : string [ ] | null = null ;
92+ try {
93+ const controller = new AbortController ( ) ;
94+ const timer = setTimeout ( ( ) => controller . abort ( ) ,
95+ DISCOVERY_PROBE_TIMEOUT_MS ) ;
96+ try {
97+ const r = await fetch ( DISCOVERY_PROBE_URL , {
98+ method : 'GET' ,
99+ signal : controller . signal ,
100+ headers : {
101+ accept : 'application/json' ,
102+ // Same WAF gate header helper.ts uses for /relays.
103+ 'x-fula-client' : 'app' ,
104+ } ,
105+ } ) ;
106+ if ( r . status >= 200 && r . status < 300 ) {
107+ discovery = 'ok' ;
108+ try {
109+ const list = ( await r . json ( ) ) as Array < { dnsName ?: string } > ;
110+ if ( Array . isArray ( list ) ) {
111+ liveDnsNames = list
112+ . map ( x => x ?. dnsName )
113+ . filter ( ( x ) : x is string => typeof x === 'string' && x . length > 0 ) ;
114+ }
115+ } catch {
116+ // Body parse failed but discovery is reachable; we'll
117+ // fall through to cache/hardcoded for the relay list.
118+ }
119+ }
120+ } finally {
121+ clearTimeout ( timer ) ;
122+ }
123+ } catch {
124+ discovery = 'failed' ;
125+ }
126+
127+ if ( liveDnsNames && liveDnsNames . length > 0 ) {
128+ return { discovery, dnsNames : liveDnsNames } ;
129+ }
130+
131+ // Discovery returned nothing usable (or failed entirely). Try cache.
132+ try {
133+ const raw = await AsyncStorage . getItem ( Constants . FXRelayCacheKey ) ;
134+ if ( raw ) {
135+ const parsed = JSON . parse ( raw ) as { list ?: Array < { dnsName ?: string } > } ;
136+ const cached = ( parsed ?. list ?? [ ] )
137+ . map ( x => x ?. dnsName )
138+ . filter ( ( x ) : x is string => typeof x === 'string' && x . length > 0 ) ;
139+ if ( cached . length > 0 ) {
140+ return { discovery, dnsNames : cached } ;
141+ }
142+ }
143+ } catch {
144+ // Cache unreadable; fall through to hardcoded.
145+ }
146+
147+ // Last-resort: parse the hardcoded relay multiaddr to get a dnsName so
148+ // the user still sees one relay row to probe.
149+ const m = Constants . FXRelay . match ( / ^ \/ d n s \/ ( [ ^ / ] + ) / ) ;
150+ const hardcoded = m ? [ m [ 1 ] ] : [ ] ;
151+ return { discovery, dnsNames : hardcoded } ;
152+ }
153+
154+ // Probe a single relay's hostname for HTTPS reachability on port 443. This
155+ // is NOT the same as libp2p reachability — Fula relays serve libp2p on TCP
156+ // 4001, and React Native's fetch can only do HTTPS. On a network that blocks
157+ // 4001 but allows 443 (some corporate firewalls), this probe will show ✓
158+ // while real connections still fail.
159+ //
160+ // Why we ship it anyway: Fula relays are Cloudflare-fronted, so :443 and
161+ // :4001 share DNS + network path. A green ✓ usually means the relay's host
162+ // is reachable; a red ✗ definitively means something is blocking the host.
163+ // The user-facing i18n copy ("HTTPS reachable" rather than "reachable")
164+ // reflects this narrowness. A real TCP/4001 probe would need
165+ // react-native-tcp-socket (not currently a dep); tracked as a follow-up.
166+ //
167+ // Any HTTP response (2xx/3xx/4xx/5xx) means TCP+TLS succeeded — that's the
168+ // signal we care about. The relay may legitimately return 404 on /, since
169+ // it doesn't serve HTTP routes; that's still "reachable" for our purposes.
170+ async function probeRelay ( dnsName : string ) : Promise < ProbeStatus > {
171+ try {
172+ const controller = new AbortController ( ) ;
173+ const timer = setTimeout ( ( ) => controller . abort ( ) , RELAY_PROBE_TIMEOUT_MS ) ;
174+ try {
175+ const r = await fetch ( `https://${ dnsName } /` , {
176+ method : 'HEAD' ,
177+ signal : controller . signal ,
178+ } ) ;
179+ // Any standard HTTP status counts as reachable — including 4xx/5xx
180+ // because those still prove TCP+TLS+HTTP completed. The only
181+ // "unreachable" outcomes are network errors / DNS fail / timeout,
182+ // all of which throw rather than resolve.
183+ return r . status >= 100 && r . status < 600 ? 'ok' : 'failed' ;
184+ } finally {
185+ clearTimeout ( timer ) ;
186+ }
187+ } catch {
188+ return 'failed' ;
189+ }
190+ }
191+
74192export const DiagnosticsScreen : React . FC = ( ) => {
75193 const { t } = useTranslation ( ) ;
76194 const { colors } = useFxTheme ( ) ;
77195 const { listActivePlugins, activePlugins } = usePluginsStore ( ) ;
78196
79197 const [ netInfoConnected , setNetInfoConnected ] = React . useState < boolean | null > ( null ) ;
80198 const [ phoneInternet , setPhoneInternet ] = React . useState < ProbeStatus > ( 'checking' ) ;
199+ const [ discoveryStatus , setDiscoveryStatus ] = React . useState < ProbeStatus > ( 'checking' ) ;
200+ const [ relays , setRelays ] = React . useState < RelayInfo [ ] | null > ( null ) ;
81201 const [ pluginPresence , setPluginPresence ] = React . useState < PluginPresence > ( 'checking' ) ;
82202
83203 // Phone-side probes run once on mount. Re-fetching on every focus would
@@ -92,6 +212,38 @@ export const DiagnosticsScreen: React.FC = () => {
92212 return ( ) => unsub ( ) ;
93213 } , [ ] ) ;
94214
215+ // Fula network reachability: discovery API + per-relay TCP probes.
216+ // Discovery resolves to a list of relay DNS names; we then probe each
217+ // relay in parallel. State is set as results arrive so the UI shows
218+ // progress without waiting for the slowest relay.
219+ React . useEffect ( ( ) => {
220+ let cancelled = false ;
221+ ( async ( ) => {
222+ const { discovery, dnsNames } = await probeDiscoveryAndListRelays ( ) ;
223+ if ( cancelled ) return ;
224+ setDiscoveryStatus ( discovery ) ;
225+ if ( dnsNames . length === 0 ) {
226+ setRelays ( [ ] ) ;
227+ return ;
228+ }
229+ // Seed the relay list with 'checking' so the user sees the
230+ // hostnames immediately; per-relay status updates as probes
231+ // resolve.
232+ setRelays ( dnsNames . map ( dnsName => ( { dnsName, status : 'checking' } ) ) ) ;
233+ await Promise . all ( dnsNames . map ( async ( dnsName ) => {
234+ const status = await probeRelay ( dnsName ) ;
235+ if ( cancelled ) return ;
236+ setRelays ( prev => {
237+ if ( ! prev ) return prev ;
238+ return prev . map ( r =>
239+ r . dnsName === dnsName ? { ...r , status } : r
240+ ) ;
241+ } ) ;
242+ } ) ) ;
243+ } ) ( ) ;
244+ return ( ) => { cancelled = true ; } ;
245+ } , [ ] ) ;
246+
95247 // Plugin presence — tolerant of pre-plugin firmware per Codex review:
96248 // missing data ≠ "not installed", so the UI copy avoids overclaiming.
97249 //
@@ -176,6 +328,83 @@ export const DiagnosticsScreen: React.FC = () => {
176328
177329 < FxSpacer height = { 12 } />
178330
331+ { /* ───────── Fula network reachability ───────── */ }
332+ < FxCard >
333+ < FxCard . Title >
334+ { t ( 'diagnostics.fulaNetworkTitle' ) }
335+ </ FxCard . Title >
336+ < FxBox paddingVertical = "8" >
337+ { discoveryStatus === 'checking' ? (
338+ < FxBox flexDirection = "row" alignItems = "center" >
339+ < ActivityIndicator size = "small" />
340+ < FxSpacer width = { 8 } />
341+ < FxText > { t ( 'diagnostics.discoveryChecking' ) } </ FxText >
342+ </ FxBox >
343+ ) : discoveryStatus === 'ok' ? (
344+ < FxText color = "successBase" >
345+ { t ( 'diagnostics.discoveryOk' ) }
346+ </ FxText >
347+ ) : (
348+ < FxText color = "errorBase" >
349+ { t ( 'diagnostics.discoveryFailed' ) }
350+ </ FxText >
351+ ) }
352+ < FxSpacer height = { 8 } />
353+ { relays === null ? (
354+ < FxBox flexDirection = "row" alignItems = "center" >
355+ < ActivityIndicator size = "small" />
356+ < FxSpacer width = { 8 } />
357+ < FxText > { t ( 'diagnostics.relaysChecking' ) } </ FxText >
358+ </ FxBox >
359+ ) : relays . length === 0 ? (
360+ < FxText variant = "bodySmallRegular" >
361+ { t ( 'diagnostics.relaysUnknown' ) }
362+ </ FxText >
363+ ) : (
364+ < >
365+ < FxText variant = "bodySmallRegular" >
366+ { t ( 'diagnostics.relaysListLabel' ) }
367+ </ FxText >
368+ < FxSpacer height = { 4 } />
369+ { relays . map ( ( r ) => (
370+ < FxBox
371+ key = { r . dnsName }
372+ flexDirection = "row"
373+ alignItems = "center"
374+ paddingVertical = "4"
375+ >
376+ { r . status === 'checking' ? (
377+ < >
378+ < ActivityIndicator size = "small" />
379+ < FxSpacer width = { 8 } />
380+ < FxText variant = "bodySmallRegular" >
381+ { r . dnsName }
382+ </ FxText >
383+ </ >
384+ ) : r . status === 'ok' ? (
385+ < FxText
386+ variant = "bodySmallRegular"
387+ color = "successBase"
388+ >
389+ ✓ { r . dnsName }
390+ </ FxText >
391+ ) : (
392+ < FxText
393+ variant = "bodySmallRegular"
394+ color = "errorBase"
395+ >
396+ ✗ { r . dnsName }
397+ </ FxText >
398+ ) }
399+ </ FxBox >
400+ ) ) }
401+ </ >
402+ ) }
403+ </ FxBox >
404+ </ FxCard >
405+
406+ < FxSpacer height = { 12 } />
407+
179408 { /* ───────── Plugin presence ───────── */ }
180409 < FxCard >
181410 < FxCard . Title > { t ( 'diagnostics.pluginStatusTitle' ) } </ FxCard . Title >
0 commit comments