mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-14 16:32:57 +01:00
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>
This commit is contained in:
274
app/_layout.tsx
274
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() {
|
||||
>
|
||||
<JellyfinProvider>
|
||||
<InactivityProvider>
|
||||
<ServerUrlProvider>
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
header: () => null,
|
||||
<WifiSsidProvider>
|
||||
<ServerUrlProvider>
|
||||
<NetworkStatusProvider>
|
||||
<PlaySettingsProvider>
|
||||
<LogProvider>
|
||||
<WebSocketProvider>
|
||||
<DownloadProvider>
|
||||
<MusicPlayerProvider>
|
||||
<GlobalModalProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<IntroSheetProvider>
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<SystemBars style='light' hidden={false} />
|
||||
<Stack initialRouteName='(auth)/(tabs)'>
|
||||
<Stack.Screen
|
||||
name='(auth)/(tabs)'
|
||||
options={{
|
||||
headerShown: false,
|
||||
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
|
||||
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
|
||||
/>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</ServerUrlProvider>
|
||||
{!Platform.isTV && <GlobalModal />}
|
||||
</ThemeProvider>
|
||||
</IntroSheetProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GlobalModalProvider>
|
||||
</MusicPlayerProvider>
|
||||
</DownloadProvider>
|
||||
</WebSocketProvider>
|
||||
</LogProvider>
|
||||
</PlaySettingsProvider>
|
||||
</NetworkStatusProvider>
|
||||
</ServerUrlProvider>
|
||||
</WifiSsidProvider>
|
||||
</InactivityProvider>
|
||||
</JellyfinProvider>
|
||||
</PersistQueryClientProvider>
|
||||
|
||||
@@ -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({
|
||||
</Text>
|
||||
<Text className={urlTypeColor}>{urlType}</Text>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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<LocalNetworkConfig>(DEFAULT_CONFIG);
|
||||
@@ -195,18 +233,23 @@ export function LocalNetworkSettings(): React.ReactElement | null {
|
||||
)}
|
||||
</ListGroup>
|
||||
|
||||
<View className='py-2'>
|
||||
<Button
|
||||
onPress={handleAddCurrentNetwork}
|
||||
disabled={!currentSSID || permissionStatus !== "granted"}
|
||||
>
|
||||
{addNetworkButtonText}
|
||||
</Button>
|
||||
</View>
|
||||
{!locationBlocked && (
|
||||
<View className='py-2'>
|
||||
<Button
|
||||
onPress={handleAddCurrentNetwork}
|
||||
disabled={!currentSSID || permissionStatus !== "granted"}
|
||||
>
|
||||
{addNetworkButtonText}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<StatusDisplay
|
||||
currentSSID={currentSSID}
|
||||
connectedToWifi={connectedToWifi}
|
||||
isUsingLocalUrl={isUsingLocalUrl}
|
||||
locationBlocked={locationBlocked}
|
||||
onOpenLocationSettings={handleOpenLocationSettings}
|
||||
t={t}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -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<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,
|
||||
};
|
||||
}
|
||||
export {
|
||||
type PermissionStatus,
|
||||
type UseWifiSSIDReturn,
|
||||
useWifiSsid as useWifiSSID,
|
||||
} from "@/providers/WifiSsidProvider";
|
||||
|
||||
19
modules/wifi-ssid/android/build.gradle
Normal file
19
modules/wifi-ssid/android/build.gradle
Normal 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 {
|
||||
}
|
||||
9
modules/wifi-ssid/android/src/main/AndroidManifest.xml
Normal file
9
modules/wifi-ssid/android/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string | null> {
|
||||
export async function getSSID(): Promise<WifiSsidResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null>(
|
||||
@@ -107,6 +108,7 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
|
||||
effectiveServerUrl,
|
||||
isUsingLocalUrl,
|
||||
currentSSID: ssid,
|
||||
connectedToWifi,
|
||||
refreshUrlState,
|
||||
}}
|
||||
>
|
||||
|
||||
153
providers/WifiSsidProvider.tsx
Normal file
153
providers/WifiSsidProvider.tsx
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user