diff --git a/app/_layout.tsx b/app/_layout.tsx
index ed6a5beec..304a40be7 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -27,6 +27,7 @@ import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
+import { WifiSsidProvider } from "@/providers/WifiSsidProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
@@ -421,143 +422,146 @@ function Layout() {
>
-
-
-
-
-
-
-
-
-
-
-
-
-
- null,
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ null,
+ }}
+ />
+ null,
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
- null,
- }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!Platform.isTV && }
-
-
-
-
-
-
-
-
-
-
-
+ {!Platform.isTV && }
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/settings/LocalNetworkSettings.tsx b/components/settings/LocalNetworkSettings.tsx
index 8bf99dbd7..31200ba5c 100644
--- a/components/settings/LocalNetworkSettings.tsx
+++ b/components/settings/LocalNetworkSettings.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { Switch, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
import { useWifiSSID } from "@/hooks/useWifiSSID";
+import { openLocationSettings } from "@/modules/wifi-ssid";
import { useServerUrl } from "@/providers/ServerUrlProvider";
import { storage } from "@/utils/mmkv";
import {
@@ -26,16 +27,26 @@ const DEFAULT_CONFIG: LocalNetworkConfig = {
interface StatusDisplayProps {
currentSSID: string | null;
+ connectedToWifi: boolean;
isUsingLocalUrl: boolean;
+ locationBlocked: boolean;
+ onOpenLocationSettings: () => void;
t: (key: string) => string;
}
function StatusDisplay({
currentSSID,
+ connectedToWifi,
isUsingLocalUrl,
+ locationBlocked,
+ onOpenLocationSettings,
t,
}: StatusDisplayProps): React.ReactElement {
- const wifiStatus = currentSSID ?? t("home.settings.network.not_connected");
+ const wifiStatus = currentSSID
+ ? currentSSID
+ : connectedToWifi
+ ? t("home.settings.network.ssid_hidden")
+ : t("home.settings.network.not_connected");
const urlType = isUsingLocalUrl
? t("home.settings.network.local")
: t("home.settings.network.remote");
@@ -55,6 +66,23 @@ function StatusDisplay({
{urlType}
+
+ {locationBlocked && (
+
+
+ {t("home.settings.network.location_off_description")}
+
+
+
+ {t("home.settings.network.open_location_settings")}
+
+
+
+ )}
);
}
@@ -62,7 +90,17 @@ function StatusDisplay({
export function LocalNetworkSettings(): React.ReactElement | null {
const { t } = useTranslation();
const { permissionStatus, requestPermission } = useWifiSSID();
- const { isUsingLocalUrl, currentSSID, refreshUrlState } = useServerUrl();
+ const { isUsingLocalUrl, currentSSID, connectedToWifi, refreshUrlState } =
+ useServerUrl();
+
+ // Connected to Wi-Fi and have permission, but the OS won't reveal the SSID —
+ // on Android this means device Location services are off.
+ const locationBlocked =
+ permissionStatus === "granted" && connectedToWifi && !currentSSID;
+
+ const handleOpenLocationSettings = useCallback(() => {
+ openLocationSettings();
+ }, []);
const remoteUrl = storage.getString("serverUrl");
const [config, setConfig] = useState(DEFAULT_CONFIG);
@@ -195,18 +233,23 @@ export function LocalNetworkSettings(): React.ReactElement | null {
)}
-
-
-
+ {!locationBlocked && (
+
+
+
+ )}
diff --git a/hooks/useWifiSSID.ts b/hooks/useWifiSSID.ts
index de0e28285..ed5fbb451 100644
--- a/hooks/useWifiSSID.ts
+++ b/hooks/useWifiSSID.ts
@@ -1,125 +1,5 @@
-import { useCallback, useEffect, useState } from "react";
-import { Platform } from "react-native";
-import { getSSID } from "@/modules/wifi-ssid";
-
-export type PermissionStatus =
- | "granted"
- | "denied"
- | "undetermined"
- | "unavailable";
-
-export interface UseWifiSSIDReturn {
- ssid: string | null;
- permissionStatus: PermissionStatus;
- requestPermission: () => Promise;
- isLoading: boolean;
-}
-
-// WiFi SSID is not available on tvOS
-if (Platform.isTV) {
- // Export a stub hook for tvOS
- module.exports = {
- useWifiSSID: (): UseWifiSSIDReturn => ({
- ssid: null,
- permissionStatus: "unavailable" as PermissionStatus,
- requestPermission: async () => false,
- isLoading: false,
- }),
- };
-}
-
-// Only import Location on non-TV platforms
-const Location = Platform.isTV ? null : require("expo-location");
-
-function mapLocationStatus(status: number | undefined): PermissionStatus {
- if (!Location) return "unavailable";
- switch (status) {
- case Location.PermissionStatus?.GRANTED:
- return "granted";
- case Location.PermissionStatus?.DENIED:
- return "denied";
- default:
- return "undetermined";
- }
-}
-
-export function useWifiSSID(): UseWifiSSIDReturn {
- const [ssid, setSSID] = useState(null);
- const [permissionStatus, setPermissionStatus] = useState(
- Platform.isTV ? "unavailable" : "undetermined",
- );
- const [isLoading, setIsLoading] = useState(!Platform.isTV);
-
- const fetchSSID = useCallback(async () => {
- if (Platform.isTV) return;
- const result = await getSSID();
- setSSID(result);
- }, []);
-
- const requestPermission = useCallback(async (): Promise => {
- if (Platform.isTV || !Location) {
- setPermissionStatus("unavailable");
- return false;
- }
-
- try {
- const { status } = await Location.requestForegroundPermissionsAsync();
- const newStatus = mapLocationStatus(status);
- setPermissionStatus(newStatus);
-
- if (newStatus === "granted") {
- await fetchSSID();
- }
-
- return newStatus === "granted";
- } catch {
- setPermissionStatus("unavailable");
- return false;
- }
- }, [fetchSSID]);
-
- useEffect(() => {
- if (Platform.isTV || !Location) {
- setIsLoading(false);
- return;
- }
-
- async function initialize() {
- setIsLoading(true);
- try {
- const { status } = await Location.getForegroundPermissionsAsync();
- const mappedStatus = mapLocationStatus(status);
- setPermissionStatus(mappedStatus);
-
- if (mappedStatus === "granted") {
- await fetchSSID();
- }
- } catch {
- setPermissionStatus("unavailable");
- }
- setIsLoading(false);
- }
-
- initialize();
- }, [fetchSSID]);
-
- // Refresh SSID when permission status changes to granted
- useEffect(() => {
- if (Platform.isTV) return;
-
- if (permissionStatus === "granted") {
- fetchSSID();
-
- // Also set up an interval to periodically check SSID
- const interval = setInterval(fetchSSID, 10000); // Check every 10 seconds
- return () => clearInterval(interval);
- }
- }, [permissionStatus, fetchSSID]);
-
- return {
- ssid,
- permissionStatus,
- requestPermission,
- isLoading,
- };
-}
+export {
+ type PermissionStatus,
+ type UseWifiSSIDReturn,
+ useWifiSsid as useWifiSSID,
+} from "@/providers/WifiSsidProvider";
diff --git a/modules/wifi-ssid/android/build.gradle b/modules/wifi-ssid/android/build.gradle
new file mode 100644
index 000000000..2474f30a3
--- /dev/null
+++ b/modules/wifi-ssid/android/build.gradle
@@ -0,0 +1,19 @@
+apply plugin: 'expo-module-gradle-plugin'
+
+group = 'expo.modules.wifissid'
+version = '1.0.0'
+
+expoModule {
+ canBePublished false
+}
+
+android {
+ namespace "expo.modules.wifissid"
+ defaultConfig {
+ versionCode 1
+ versionName "1.0.0"
+ }
+}
+
+dependencies {
+}
diff --git a/modules/wifi-ssid/android/src/main/AndroidManifest.xml b/modules/wifi-ssid/android/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..7e21f89b1
--- /dev/null
+++ b/modules/wifi-ssid/android/src/main/AndroidManifest.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/modules/wifi-ssid/android/src/main/java/expo/modules/wifissid/WifiSsidModule.kt b/modules/wifi-ssid/android/src/main/java/expo/modules/wifissid/WifiSsidModule.kt
new file mode 100644
index 000000000..82a0f7640
--- /dev/null
+++ b/modules/wifi-ssid/android/src/main/java/expo/modules/wifissid/WifiSsidModule.kt
@@ -0,0 +1,109 @@
+package expo.modules.wifissid
+
+import android.Manifest
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.wifi.WifiInfo
+import android.net.wifi.WifiManager
+import android.os.Build
+import android.provider.Settings
+import android.util.Log
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class WifiSsidModule : Module() {
+ companion object {
+ private const val TAG = "WifiSsid"
+ private const val UNKNOWN_SSID = ""
+ }
+
+ private val context
+ get() = requireNotNull(appContext.reactContext)
+
+ override fun definition() = ModuleDefinition {
+ Name("WifiSsid")
+
+ AsyncFunction("getSSID") {
+ val (ssid, connectedToWifi) = readWifi()
+ mapOf(
+ "ssid" to ssid,
+ "connectedToWifi" to connectedToWifi,
+ )
+ }
+
+ Function("getSSIDSync") {
+ val (ssid, connectedToWifi) = readWifi()
+ mapOf(
+ "ssid" to ssid,
+ "connectedToWifi" to connectedToWifi,
+ )
+ }
+
+ Function("openLocationSettings") {
+ openLocationSettings()
+ }
+ }
+
+ /**
+ * Returns the current Wi-Fi SSID (or null when it can't be read) and whether
+ * the device is connected to a Wi-Fi network. When the device is connected but
+ * the SSID is null, the OS is withholding the network name — on Android this
+ * typically means device Location services are off (the SSID is treated as
+ * location-sensitive).
+ */
+ private fun readWifi(): Pair {
+ val appContext = context.applicationContext
+ var connectedToWifi = false
+ var ssid: String? = null
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ try {
+ val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
+ val caps = cm?.let { manager ->
+ manager.activeNetwork?.let { net -> manager.getNetworkCapabilities(net) }
+ }
+ connectedToWifi = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
+ if (hasFineLocationPermission()) {
+ ssid = (caps?.transportInfo as? WifiInfo)?.ssid?.sanitize()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error reading Wi-Fi via ConnectivityManager", e)
+ }
+ }
+
+ if (ssid == null && hasFineLocationPermission()) {
+ try {
+ val wm = appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
+ @Suppress("DEPRECATION")
+ ssid = wm?.connectionInfo?.ssid?.sanitize()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error reading SSID via WifiManager", e)
+ }
+ }
+
+ return Pair(ssid, connectedToWifi)
+ }
+
+ private fun openLocationSettings() {
+ try {
+ val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.applicationContext.startActivity(intent)
+ } catch (e: Exception) {
+ Log.e(TAG, "Unable to open Location settings", e)
+ }
+ }
+
+ private fun hasFineLocationPermission(): Boolean =
+ context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
+ PackageManager.PERMISSION_GRANTED
+
+ private fun String.sanitize(): String? {
+ val cleaned = trim('"')
+ return if (cleaned.isBlank() || cleaned == UNKNOWN_SSID) null else cleaned
+ }
+}
diff --git a/modules/wifi-ssid/expo-module.config.json b/modules/wifi-ssid/expo-module.config.json
index 0b38b99e7..fff984494 100644
--- a/modules/wifi-ssid/expo-module.config.json
+++ b/modules/wifi-ssid/expo-module.config.json
@@ -1,8 +1,11 @@
{
"name": "wifi-ssid",
"version": "1.0.0",
- "platforms": ["ios"],
+ "platforms": ["ios", "android"],
"ios": {
"modules": ["WifiSsidModule"]
+ },
+ "android": {
+ "modules": ["expo.modules.wifissid.WifiSsidModule"]
}
}
diff --git a/modules/wifi-ssid/index.ts b/modules/wifi-ssid/index.ts
index 00d2bf838..56af086c9 100644
--- a/modules/wifi-ssid/index.ts
+++ b/modules/wifi-ssid/index.ts
@@ -1,44 +1,92 @@
import { Platform, requireNativeModule } from "expo-modules-core";
+import { Linking } from "react-native";
-// Only load the native module on iOS
-const WifiSsidModule =
- Platform.OS === "ios" ? requireNativeModule("WifiSsid") : null;
+export interface WifiSsidResult {
+ ssid: string | null;
+ /**
+ * Whether the device is connected to a Wi-Fi network. When true but `ssid` is
+ * null, the OS is withholding the network name — on Android this happens when
+ * device Location services are off (the SSID is location-sensitive).
+ */
+ connectedToWifi: boolean;
+}
+
+// Load the native module on iOS and Android (not TV/web). Wrapped so a
+// missing/unlinked module degrades to null instead of crashing the bundle.
+const WifiSsidModule = (() => {
+ if (Platform.OS !== "ios" && Platform.OS !== "android") return null;
+ try {
+ return requireNativeModule("WifiSsid");
+ } catch {
+ return null;
+ }
+})();
/**
- * Get the current WiFi SSID on iOS.
- * Returns null on Android or if not connected to WiFi.
+ * Get the current WiFi SSID and whether the device is on a Wi-Fi network.
*
- * Requires:
+ * iOS requirements:
* - Location permission granted
* - com.apple.developer.networking.wifi-info entitlement
- * - Access WiFi Information capability enabled in Apple Developer Portal
+ *
+ * Android requirements:
+ * - ACCESS_FINE_LOCATION granted at runtime, and device Location services on
*/
-export async function getSSID(): Promise {
+export async function getSSID(): Promise {
if (!WifiSsidModule) {
- return null;
+ return { ssid: null, connectedToWifi: false };
}
-
try {
- const ssid = await WifiSsidModule.getSSID();
- return ssid ?? null;
+ return normalize(await WifiSsidModule.getSSID());
} catch (error) {
console.error("[WifiSsid] Error getting SSID:", error);
- return null;
+ return { ssid: null, connectedToWifi: false };
+ }
+}
+
+/** Synchronous version (uses the older CNCopyCurrentNetworkInfo API on iOS). */
+export function getSSIDSync(): WifiSsidResult {
+ if (!WifiSsidModule) {
+ return { ssid: null, connectedToWifi: false };
+ }
+ try {
+ return normalize(WifiSsidModule.getSSIDSync());
+ } catch (error) {
+ console.error("[WifiSsid] Error getting SSID (sync):", error);
+ return { ssid: null, connectedToWifi: false };
}
}
/**
- * Synchronous version - uses older CNCopyCurrentNetworkInfo API
+ * Open the system Location settings so the user can enable Location services
+ * (required on Android for the OS to reveal the Wi-Fi SSID). Falls back to the
+ * app's settings page where there is no dedicated native action (e.g. iOS).
*/
-export function getSSIDSync(): string | null {
- if (!WifiSsidModule) {
- return null;
- }
-
+export function openLocationSettings(): void {
try {
- return WifiSsidModule.getSSIDSync() ?? null;
+ if (WifiSsidModule?.openLocationSettings) {
+ WifiSsidModule.openLocationSettings();
+ return;
+ }
} catch (error) {
- console.error("[WifiSsid] Error getting SSID (sync):", error);
- return null;
+ console.error("[WifiSsid] Error opening location settings:", error);
}
+ Linking.openSettings();
+}
+
+// Both native modules return { ssid, connectedToWifi }. The string branch is a
+// defensive fallback in case a build returns the older plain-string shape.
+function normalize(result: unknown): WifiSsidResult {
+ if (typeof result === "string") {
+ const ssid = result || null;
+ return { ssid, connectedToWifi: ssid !== null };
+ }
+ if (result && typeof result === "object") {
+ const obj = result as { ssid?: string | null; connectedToWifi?: boolean };
+ return {
+ ssid: obj.ssid ?? null,
+ connectedToWifi: Boolean(obj.connectedToWifi),
+ };
+ }
+ return { ssid: null, connectedToWifi: false };
}
diff --git a/modules/wifi-ssid/ios/WifiSsidModule.swift b/modules/wifi-ssid/ios/WifiSsidModule.swift
index 5254fcb5d..9029c9290 100644
--- a/modules/wifi-ssid/ios/WifiSsidModule.swift
+++ b/modules/wifi-ssid/ios/WifiSsidModule.swift
@@ -1,50 +1,109 @@
import ExpoModulesCore
#if !os(tvOS)
+import Network
import NetworkExtension
import SystemConfiguration.CaptiveNetwork
#endif
+import UIKit
public class WifiSsidModule: Module {
+ #if !os(tvOS)
+ // NWPathMonitor reports whether the active path uses Wi-Fi WITHOUT requiring
+ // the wifi-info entitlement, so we can tell "on Wi-Fi but can't read the SSID"
+ // apart from "not connected to Wi-Fi" even when fetchCurrent returns nil.
+ private let pathMonitor = NWPathMonitor()
+ private let monitorQueue = DispatchQueue(label: "expo.modules.wifissid.pathmonitor")
+ private let lock = NSLock()
+ private var cachedOnWifi = false
+ private var pathMonitorStarted = false
+
+ private var isOnWifi: Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return cachedOnWifi
+ }
+
+ private func startPathMonitorIfNeeded() {
+ lock.lock()
+ if pathMonitorStarted {
+ lock.unlock()
+ return
+ }
+ pathMonitorStarted = true
+ lock.unlock()
+
+ pathMonitor.pathUpdateHandler = { [weak self] path in
+ guard let self else { return }
+ let onWifi = path.status == .satisfied && path.usesInterfaceType(.wifi)
+ self.lock.lock()
+ self.cachedOnWifi = onWifi
+ self.lock.unlock()
+ }
+ pathMonitor.start(queue: monitorQueue)
+ }
+ #endif
+
public func definition() -> ModuleDefinition {
Name("WifiSsid")
- // Get current WiFi SSID using NEHotspotNetwork (iOS 14+)
- // Not available on tvOS
- AsyncFunction("getSSID") { () -> String? in
+ AsyncFunction("getSSID") { () -> [String: Any] in
#if os(tvOS)
- return nil
+ return ["ssid": NSNull(), "connectedToWifi": false]
#else
- return await withCheckedContinuation { continuation in
- NEHotspotNetwork.fetchCurrent { network in
- if let ssid = network?.ssid {
- print("[WifiSsid] Got SSID via NEHotspotNetwork: \(ssid)")
- continuation.resume(returning: ssid)
- } else {
- // Fallback to CNCopyCurrentNetworkInfo for older iOS
- print("[WifiSsid] NEHotspotNetwork returned nil, trying CNCopyCurrentNetworkInfo")
- let ssid = self.getSSIDViaCNCopy()
- continuation.resume(returning: ssid)
- }
- }
+ self.startPathMonitorIfNeeded()
+ let ssid = await self.resolveSSID()
+ var result: [String: Any] = ["connectedToWifi": self.isOnWifi]
+ if let ssid = ssid {
+ result["ssid"] = ssid
+ } else {
+ result["ssid"] = NSNull()
}
+ return result
#endif
}
- // Synchronous version using only CNCopyCurrentNetworkInfo
- // Not available on tvOS
- Function("getSSIDSync") { () -> String? in
+ Function("getSSIDSync") { () -> [String: Any] in
#if os(tvOS)
- return nil
+ return ["ssid": NSNull(), "connectedToWifi": false]
#else
- return self.getSSIDViaCNCopy()
+ self.startPathMonitorIfNeeded()
+ let ssid = self.getSSIDViaCNCopy()
+ var result: [String: Any] = ["connectedToWifi": self.isOnWifi]
+ if let ssid = ssid {
+ result["ssid"] = ssid
+ } else {
+ result["ssid"] = NSNull()
+ }
+ return result
+ #endif
+ }
+
+ Function("openLocationSettings") {
+ #if !os(tvOS)
+ DispatchQueue.main.async {
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ UIApplication.shared.open(url)
+ }
+ }
#endif
}
}
#if !os(tvOS)
+ private func resolveSSID() async -> String? {
+ await withCheckedContinuation { continuation in
+ NEHotspotNetwork.fetchCurrent { network in
+ if let ssid = network?.ssid {
+ continuation.resume(returning: ssid)
+ } else {
+ continuation.resume(returning: self.getSSIDViaCNCopy())
+ }
+ }
+ }
+ }
+
private func getSSIDViaCNCopy() -> String? {
guard let interfaces = CNCopySupportedInterfaces() as? [String] else {
- print("[WifiSsid] CNCopySupportedInterfaces returned nil")
return nil
}
@@ -54,12 +113,10 @@ public class WifiSsidModule: Module {
}
if let ssid = networkInfo[kCNNetworkInfoKeySSID as String] as? String {
- print("[WifiSsid] Got SSID via CNCopyCurrentNetworkInfo: \(ssid)")
return ssid
}
}
- print("[WifiSsid] No SSID found via CNCopyCurrentNetworkInfo")
return nil
}
#endif
diff --git a/providers/ServerUrlProvider.tsx b/providers/ServerUrlProvider.tsx
index f73eb907f..e5a49801c 100644
--- a/providers/ServerUrlProvider.tsx
+++ b/providers/ServerUrlProvider.tsx
@@ -18,6 +18,7 @@ interface ServerUrlContextValue {
effectiveServerUrl: string | null;
isUsingLocalUrl: boolean;
currentSSID: string | null;
+ connectedToWifi: boolean;
refreshUrlState: () => void;
}
@@ -32,7 +33,7 @@ interface Props {
export function ServerUrlProvider({ children }: Props): React.ReactElement {
const api = useAtomValue(apiAtom);
const { switchServerUrl } = useJellyfin();
- const { ssid, permissionStatus } = useWifiSSID();
+ const { ssid, connectedToWifi, permissionStatus } = useWifiSSID();
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
const [effectiveServerUrl, setEffectiveServerUrl] = useState(
@@ -107,6 +108,7 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
effectiveServerUrl,
isUsingLocalUrl,
currentSSID: ssid,
+ connectedToWifi,
refreshUrlState,
}}
>
diff --git a/providers/WifiSsidProvider.tsx b/providers/WifiSsidProvider.tsx
new file mode 100644
index 000000000..4dcfca3e3
--- /dev/null
+++ b/providers/WifiSsidProvider.tsx
@@ -0,0 +1,153 @@
+import type React from "react";
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { Platform } from "react-native";
+import { getSSID } from "@/modules/wifi-ssid";
+
+export type PermissionStatus =
+ | "granted"
+ | "denied"
+ | "undetermined"
+ | "unavailable";
+
+export interface UseWifiSSIDReturn {
+ ssid: string | null;
+ connectedToWifi: boolean;
+ permissionStatus: PermissionStatus;
+ requestPermission: () => Promise;
+ isLoading: boolean;
+}
+
+const WifiSsidContext = createContext(null);
+
+// WiFi SSID is not available on tvOS, and expo-location isn't needed there.
+const Location = Platform.isTV ? null : require("expo-location");
+
+const POLL_INTERVAL_MS = 10_000;
+
+function mapLocationStatus(status: number | undefined): PermissionStatus {
+ if (!Location) return "unavailable";
+ switch (status) {
+ case Location.PermissionStatus?.GRANTED:
+ return "granted";
+ case Location.PermissionStatus?.DENIED:
+ return "denied";
+ default:
+ return "undetermined";
+ }
+}
+
+interface Props {
+ children: ReactNode;
+}
+
+/**
+ * Single source of truth for the current WiFi SSID and the location permission
+ * that gates it. State is shared via context so that granting permission in one
+ * place (e.g. the network settings screen) is reflected everywhere else — most
+ * importantly ServerUrlProvider, which switches between the local and remote
+ * server URLs based on the connected SSID.
+ *
+ * Previously each consumer created its own useWifiSSID instance with independent
+ * state, so the provider never learned that permission had been granted later
+ * in the session and reported "not connected to WiFi" forever.
+ */
+export function WifiSsidProvider({ children }: Props): React.ReactElement {
+ const [ssid, setSSID] = useState(null);
+ const [connectedToWifi, setConnectedToWifi] = useState(false);
+ const [permissionStatus, setPermissionStatus] = useState(
+ Platform.isTV ? "unavailable" : "undetermined",
+ );
+ const [isLoading, setIsLoading] = useState(!Platform.isTV);
+
+ const fetchSSID = useCallback(async () => {
+ if (Platform.isTV) return;
+ const result = await getSSID();
+ setSSID(result.ssid);
+ setConnectedToWifi(result.connectedToWifi);
+ }, []);
+
+ const requestPermission = useCallback(async (): Promise => {
+ if (Platform.isTV || !Location) {
+ setPermissionStatus("unavailable");
+ return false;
+ }
+
+ try {
+ const { status } = await Location.requestForegroundPermissionsAsync();
+ const newStatus = mapLocationStatus(status);
+ setPermissionStatus(newStatus);
+ return newStatus === "granted";
+ } catch {
+ setPermissionStatus("unavailable");
+ return false;
+ }
+ }, []);
+
+ // Check the location permission once on mount. The poll effect below takes
+ // over fetching the SSID whenever permission becomes "granted".
+ useEffect(() => {
+ if (Platform.isTV || !Location) {
+ setIsLoading(false);
+ return;
+ }
+
+ let cancelled = false;
+ async function initialize() {
+ setIsLoading(true);
+ try {
+ const { status } = await Location.getForegroundPermissionsAsync();
+ if (cancelled) return;
+ setPermissionStatus(mapLocationStatus(status));
+ } catch {
+ if (!cancelled) setPermissionStatus("unavailable");
+ }
+ if (!cancelled) setIsLoading(false);
+ }
+
+ initialize();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ // Fetch the SSID immediately, then poll, whenever permission is granted.
+ // Because permissionStatus is shared, this also covers the case where
+ // permission was granted later (from another consumer) than this provider's
+ // mount time — the original root cause of the "not connected to WiFi" bug.
+ useEffect(() => {
+ if (Platform.isTV || permissionStatus !== "granted") return;
+
+ fetchSSID();
+ const interval = setInterval(fetchSSID, POLL_INTERVAL_MS);
+ return () => clearInterval(interval);
+ }, [permissionStatus, fetchSSID]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useWifiSsid(): UseWifiSSIDReturn {
+ const context = useContext(WifiSsidContext);
+ if (!context) {
+ throw new Error("useWifiSsid must be used within WifiSsidProvider");
+ }
+ return context;
+}
diff --git a/translations/en.json b/translations/en.json
index 3c4271b1a..f3bf6eec1 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -174,7 +174,10 @@
"network_already_added": "Network already added",
"no_wifi_connected": "Not connected to Wi-Fi",
"permission_denied": "Location permission denied",
- "permission_denied_explanation": "Location permission is required to detect the Wi-Fi network for auto-switching. Please enable it in Settings."
+ "permission_denied_explanation": "Location permission is required to detect the Wi-Fi network for auto-switching. Please enable it in Settings.",
+ "ssid_hidden": "SSID hidden",
+ "location_off_description": "Connected to Wi-Fi, but the network name is hidden. Enable device Location services and grant the app location permission to detect it.",
+ "open_location_settings": "Open Location settings"
},
"user_info": {
"user_info_title": "User info",