From 64b9224b7a6dd5904c93e745dec91901fe30749c Mon Sep 17 00:00:00 2001 From: Lance Chant <13349722+lancechant@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:43:12 +0200 Subject: [PATCH] fix: network location settings Fixed android location settings for wifi network connections as didn't exist Added some better wording to assist Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> --- app/_layout.tsx | 274 +++++++++--------- components/settings/LocalNetworkSettings.tsx | 63 +++- hooks/useWifiSSID.ts | 130 +-------- modules/wifi-ssid/android/build.gradle | 19 ++ .../android/src/main/AndroidManifest.xml | 9 + .../expo/modules/wifissid/WifiSsidModule.kt | 109 +++++++ modules/wifi-ssid/expo-module.config.json | 5 +- modules/wifi-ssid/index.ts | 92 ++++-- modules/wifi-ssid/ios/WifiSsidModule.swift | 105 +++++-- providers/ServerUrlProvider.tsx | 4 +- providers/WifiSsidProvider.tsx | 153 ++++++++++ translations/en.json | 5 +- 12 files changed, 649 insertions(+), 319 deletions(-) create mode 100644 modules/wifi-ssid/android/build.gradle create mode 100644 modules/wifi-ssid/android/src/main/AndroidManifest.xml create mode 100644 modules/wifi-ssid/android/src/main/java/expo/modules/wifissid/WifiSsidModule.kt create mode 100644 providers/WifiSsidProvider.tsx 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() { > - - - - - - - - - - - - - - - - - - - - - - + {!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",