Compare commits

..

1 Commits

Author SHA1 Message Date
Lance Chant
64b9224b7a 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>
2026-07-14 14:43:12 +02:00
14 changed files with 655 additions and 325 deletions

View File

@@ -27,6 +27,7 @@ import { NetworkStatusProvider } from "@/providers/NetworkStatusProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { ServerUrlProvider } from "@/providers/ServerUrlProvider"; import { ServerUrlProvider } from "@/providers/ServerUrlProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { WifiSsidProvider } from "@/providers/WifiSsidProvider";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { import {
BACKGROUND_FETCH_TASK, BACKGROUND_FETCH_TASK,
@@ -421,143 +422,146 @@ function Layout() {
> >
<JellyfinProvider> <JellyfinProvider>
<InactivityProvider> <InactivityProvider>
<ServerUrlProvider> <WifiSsidProvider>
<NetworkStatusProvider> <ServerUrlProvider>
<PlaySettingsProvider> <NetworkStatusProvider>
<LogProvider> <PlaySettingsProvider>
<WebSocketProvider> <LogProvider>
<DownloadProvider> <WebSocketProvider>
<MusicPlayerProvider> <DownloadProvider>
<GlobalModalProvider> <MusicPlayerProvider>
<BottomSheetModalProvider> <GlobalModalProvider>
<IntroSheetProvider> <BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}> <IntroSheetProvider>
<SystemBars style='light' hidden={false} /> <ThemeProvider value={DarkTheme}>
<Stack initialRouteName='(auth)/(tabs)'> <SystemBars style='light' hidden={false} />
<Stack.Screen <Stack initialRouteName='(auth)/(tabs)'>
name='(auth)/(tabs)' <Stack.Screen
options={{ name='(auth)/(tabs)'
headerShown: false, options={{
title: "", headerShown: false,
header: () => null, title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/now-playing'
options={{
headerShown: false,
presentation: "modal",
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent:
Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
<Stack.Screen {!Platform.isTV && <GlobalModal />}
name='(auth)/player' </ThemeProvider>
options={{ </IntroSheetProvider>
headerShown: false, </BottomSheetModalProvider>
title: "", </GlobalModalProvider>
header: () => null, </MusicPlayerProvider>
}} </DownloadProvider>
/> </WebSocketProvider>
<Stack.Screen </LogProvider>
name='(auth)/now-playing' </PlaySettingsProvider>
options={{ </NetworkStatusProvider>
headerShown: false, </ServerUrlProvider>
presentation: "modal", </WifiSsidProvider>
gestureEnabled: true,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: Platform.OS === "ios",
}}
/>
<Stack.Screen name='+not-found' />
<Stack.Screen
name='(auth)/tv-option-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-subtitle-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-request-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-season-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-series-season-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-action-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='tv-account-select-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
<Stack.Screen
name='(auth)/tv-user-switch-modal'
options={{
headerShown: false,
presentation: "transparentModal",
animation: "fade",
}}
/>
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
{!Platform.isTV && <GlobalModal />}
</ThemeProvider>
</IntroSheetProvider>
</BottomSheetModalProvider>
</GlobalModalProvider>
</MusicPlayerProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</NetworkStatusProvider>
</ServerUrlProvider>
</InactivityProvider> </InactivityProvider>
</JellyfinProvider> </JellyfinProvider>
</PersistQueryClientProvider> </PersistQueryClientProvider>

View File

@@ -57,8 +57,8 @@
"lodash": "4.18.1", "lodash": "4.18.1",
"nativewind": "^2.0.11", "nativewind": "^2.0.11",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"react": "19.2.7", "react": "19.2.3",
"react-dom": "19.2.7", "react-dom": "19.2.3",
"react-i18next": "17.0.8", "react-i18next": "17.0.8",
"react-native": "npm:react-native-tvos@0.85.3-0", "react-native": "npm:react-native-tvos@0.85.3-0",
"react-native-awesome-slider": "^2.9.0", "react-native-awesome-slider": "^2.9.0",
@@ -1571,11 +1571,11 @@
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="], "react-devtools-core": ["react-devtools-core@6.1.5", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="],

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { Switch, TouchableOpacity, View } from "react-native"; import { Switch, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { useWifiSSID } from "@/hooks/useWifiSSID"; import { useWifiSSID } from "@/hooks/useWifiSSID";
import { openLocationSettings } from "@/modules/wifi-ssid";
import { useServerUrl } from "@/providers/ServerUrlProvider"; import { useServerUrl } from "@/providers/ServerUrlProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { import {
@@ -26,16 +27,26 @@ const DEFAULT_CONFIG: LocalNetworkConfig = {
interface StatusDisplayProps { interface StatusDisplayProps {
currentSSID: string | null; currentSSID: string | null;
connectedToWifi: boolean;
isUsingLocalUrl: boolean; isUsingLocalUrl: boolean;
locationBlocked: boolean;
onOpenLocationSettings: () => void;
t: (key: string) => string; t: (key: string) => string;
} }
function StatusDisplay({ function StatusDisplay({
currentSSID, currentSSID,
connectedToWifi,
isUsingLocalUrl, isUsingLocalUrl,
locationBlocked,
onOpenLocationSettings,
t, t,
}: StatusDisplayProps): React.ReactElement { }: 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 const urlType = isUsingLocalUrl
? t("home.settings.network.local") ? t("home.settings.network.local")
: t("home.settings.network.remote"); : t("home.settings.network.remote");
@@ -55,6 +66,23 @@ function StatusDisplay({
</Text> </Text>
<Text className={urlTypeColor}>{urlType}</Text> <Text className={urlTypeColor}>{urlType}</Text>
</View> </View>
{locationBlocked && (
<View className='mt-2 pt-2 border-t border-neutral-800'>
<Text className='text-xs text-amber-400'>
{t("home.settings.network.location_off_description")}
</Text>
<TouchableOpacity
onPress={onOpenLocationSettings}
className='mt-2 self-start'
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Text className='text-xs text-blue-400 font-semibold'>
{t("home.settings.network.open_location_settings")}
</Text>
</TouchableOpacity>
</View>
)}
</View> </View>
); );
} }
@@ -62,7 +90,17 @@ function StatusDisplay({
export function LocalNetworkSettings(): React.ReactElement | null { export function LocalNetworkSettings(): React.ReactElement | null {
const { t } = useTranslation(); const { t } = useTranslation();
const { permissionStatus, requestPermission } = useWifiSSID(); 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 remoteUrl = storage.getString("serverUrl");
const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG); const [config, setConfig] = useState<LocalNetworkConfig>(DEFAULT_CONFIG);
@@ -195,18 +233,23 @@ export function LocalNetworkSettings(): React.ReactElement | null {
)} )}
</ListGroup> </ListGroup>
<View className='py-2'> {!locationBlocked && (
<Button <View className='py-2'>
onPress={handleAddCurrentNetwork} <Button
disabled={!currentSSID || permissionStatus !== "granted"} onPress={handleAddCurrentNetwork}
> disabled={!currentSSID || permissionStatus !== "granted"}
{addNetworkButtonText} >
</Button> {addNetworkButtonText}
</View> </Button>
</View>
)}
<StatusDisplay <StatusDisplay
currentSSID={currentSSID} currentSSID={currentSSID}
connectedToWifi={connectedToWifi}
isUsingLocalUrl={isUsingLocalUrl} isUsingLocalUrl={isUsingLocalUrl}
locationBlocked={locationBlocked}
onOpenLocationSettings={handleOpenLocationSettings}
t={t} t={t}
/> />
</View> </View>

View File

@@ -1,125 +1,5 @@
import { useCallback, useEffect, useState } from "react"; export {
import { Platform } from "react-native"; type PermissionStatus,
import { getSSID } from "@/modules/wifi-ssid"; type UseWifiSSIDReturn,
useWifiSsid as useWifiSSID,
export type PermissionStatus = } from "@/providers/WifiSsidProvider";
| "granted"
| "denied"
| "undetermined"
| "unavailable";
export interface UseWifiSSIDReturn {
ssid: string | null;
permissionStatus: PermissionStatus;
requestPermission: () => Promise<boolean>;
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<string | null>(null);
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus>(
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<boolean> => {
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,
};
}

View File

@@ -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 {
}

View File

@@ -0,0 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Reading the connected Wi-Fi SSID requires ACCESS_WIFI_STATE, and (since
Android 8) ACCESS_FINE_LOCATION granted at runtime. Location is requested
at runtime by the app via expo-location; the manifest declares the
Wi-Fi/network permissions this module needs directly. -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
</manifest>

View File

@@ -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 = "<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<String?, Boolean> {
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
}
}

View File

@@ -1,8 +1,11 @@
{ {
"name": "wifi-ssid", "name": "wifi-ssid",
"version": "1.0.0", "version": "1.0.0",
"platforms": ["ios"], "platforms": ["ios", "android"],
"ios": { "ios": {
"modules": ["WifiSsidModule"] "modules": ["WifiSsidModule"]
},
"android": {
"modules": ["expo.modules.wifissid.WifiSsidModule"]
} }
} }

View File

@@ -1,44 +1,92 @@
import { Platform, requireNativeModule } from "expo-modules-core"; import { Platform, requireNativeModule } from "expo-modules-core";
import { Linking } from "react-native";
// Only load the native module on iOS export interface WifiSsidResult {
const WifiSsidModule = ssid: string | null;
Platform.OS === "ios" ? requireNativeModule("WifiSsid") : 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. * Get the current WiFi SSID and whether the device is on a Wi-Fi network.
* Returns null on Android or if not connected to WiFi.
* *
* Requires: * iOS requirements:
* - Location permission granted * - Location permission granted
* - com.apple.developer.networking.wifi-info entitlement * - 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<string | null> { export async function getSSID(): Promise<WifiSsidResult> {
if (!WifiSsidModule) { if (!WifiSsidModule) {
return null; return { ssid: null, connectedToWifi: false };
} }
try { try {
const ssid = await WifiSsidModule.getSSID(); return normalize(await WifiSsidModule.getSSID());
return ssid ?? null;
} catch (error) { } catch (error) {
console.error("[WifiSsid] Error getting SSID:", 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 { export function openLocationSettings(): void {
if (!WifiSsidModule) {
return null;
}
try { try {
return WifiSsidModule.getSSIDSync() ?? null; if (WifiSsidModule?.openLocationSettings) {
WifiSsidModule.openLocationSettings();
return;
}
} catch (error) { } catch (error) {
console.error("[WifiSsid] Error getting SSID (sync):", error); console.error("[WifiSsid] Error opening location settings:", error);
return null;
} }
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 };
} }

View File

@@ -1,50 +1,109 @@
import ExpoModulesCore import ExpoModulesCore
#if !os(tvOS) #if !os(tvOS)
import Network
import NetworkExtension import NetworkExtension
import SystemConfiguration.CaptiveNetwork import SystemConfiguration.CaptiveNetwork
#endif #endif
import UIKit
public class WifiSsidModule: Module { 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 { public func definition() -> ModuleDefinition {
Name("WifiSsid") Name("WifiSsid")
// Get current WiFi SSID using NEHotspotNetwork (iOS 14+) AsyncFunction("getSSID") { () -> [String: Any] in
// Not available on tvOS
AsyncFunction("getSSID") { () -> String? in
#if os(tvOS) #if os(tvOS)
return nil return ["ssid": NSNull(), "connectedToWifi": false]
#else #else
return await withCheckedContinuation { continuation in self.startPathMonitorIfNeeded()
NEHotspotNetwork.fetchCurrent { network in let ssid = await self.resolveSSID()
if let ssid = network?.ssid { var result: [String: Any] = ["connectedToWifi": self.isOnWifi]
print("[WifiSsid] Got SSID via NEHotspotNetwork: \(ssid)") if let ssid = ssid {
continuation.resume(returning: ssid) result["ssid"] = ssid
} else { } else {
// Fallback to CNCopyCurrentNetworkInfo for older iOS result["ssid"] = NSNull()
print("[WifiSsid] NEHotspotNetwork returned nil, trying CNCopyCurrentNetworkInfo")
let ssid = self.getSSIDViaCNCopy()
continuation.resume(returning: ssid)
}
}
} }
return result
#endif #endif
} }
// Synchronous version using only CNCopyCurrentNetworkInfo Function("getSSIDSync") { () -> [String: Any] in
// Not available on tvOS
Function("getSSIDSync") { () -> String? in
#if os(tvOS) #if os(tvOS)
return nil return ["ssid": NSNull(), "connectedToWifi": false]
#else #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 #endif
} }
} }
#if !os(tvOS) #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? { private func getSSIDViaCNCopy() -> String? {
guard let interfaces = CNCopySupportedInterfaces() as? [String] else { guard let interfaces = CNCopySupportedInterfaces() as? [String] else {
print("[WifiSsid] CNCopySupportedInterfaces returned nil")
return nil return nil
} }
@@ -54,12 +113,10 @@ public class WifiSsidModule: Module {
} }
if let ssid = networkInfo[kCNNetworkInfoKeySSID as String] as? String { if let ssid = networkInfo[kCNNetworkInfoKeySSID as String] as? String {
print("[WifiSsid] Got SSID via CNCopyCurrentNetworkInfo: \(ssid)")
return ssid return ssid
} }
} }
print("[WifiSsid] No SSID found via CNCopyCurrentNetworkInfo")
return nil return nil
} }
#endif #endif

View File

@@ -80,8 +80,8 @@
"lodash": "4.18.1", "lodash": "4.18.1",
"nativewind": "^2.0.11", "nativewind": "^2.0.11",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"react": "19.2.7", "react": "19.2.3",
"react-dom": "19.2.7", "react-dom": "19.2.3",
"react-i18next": "17.0.8", "react-i18next": "17.0.8",
"react-native": "npm:react-native-tvos@0.85.3-0", "react-native": "npm:react-native-tvos@0.85.3-0",
"react-native-awesome-slider": "^2.9.0", "react-native-awesome-slider": "^2.9.0",

View File

@@ -18,6 +18,7 @@ interface ServerUrlContextValue {
effectiveServerUrl: string | null; effectiveServerUrl: string | null;
isUsingLocalUrl: boolean; isUsingLocalUrl: boolean;
currentSSID: string | null; currentSSID: string | null;
connectedToWifi: boolean;
refreshUrlState: () => void; refreshUrlState: () => void;
} }
@@ -32,7 +33,7 @@ interface Props {
export function ServerUrlProvider({ children }: Props): React.ReactElement { export function ServerUrlProvider({ children }: Props): React.ReactElement {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const { switchServerUrl } = useJellyfin(); const { switchServerUrl } = useJellyfin();
const { ssid, permissionStatus } = useWifiSSID(); const { ssid, connectedToWifi, permissionStatus } = useWifiSSID();
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false); const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>( const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>(
@@ -107,6 +108,7 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
effectiveServerUrl, effectiveServerUrl,
isUsingLocalUrl, isUsingLocalUrl,
currentSSID: ssid, currentSSID: ssid,
connectedToWifi,
refreshUrlState, refreshUrlState,
}} }}
> >

View File

@@ -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<boolean>;
isLoading: boolean;
}
const WifiSsidContext = createContext<UseWifiSSIDReturn | null>(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<string | null>(null);
const [connectedToWifi, setConnectedToWifi] = useState(false);
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus>(
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<boolean> => {
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 (
<WifiSsidContext.Provider
value={{
ssid,
connectedToWifi,
permissionStatus,
requestPermission,
isLoading,
}}
>
{children}
</WifiSsidContext.Provider>
);
}
export function useWifiSsid(): UseWifiSSIDReturn {
const context = useContext(WifiSsidContext);
if (!context) {
throw new Error("useWifiSsid must be used within WifiSsidProvider");
}
return context;
}

View File

@@ -174,7 +174,10 @@
"network_already_added": "Network already added", "network_already_added": "Network already added",
"no_wifi_connected": "Not connected to Wi-Fi", "no_wifi_connected": "Not connected to Wi-Fi",
"permission_denied": "Location permission denied", "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": {
"user_info_title": "User info", "user_info_title": "User info",