接入原生壳网络状态能力
新增 network.status 与 network.statusChanged HostBridge 能力 Expo 壳通过 expo-network 查询并订阅真实网络状态 Tauri 壳通过主站可达性查询和 WebView online/offline 事件同步网络状态 更新壳能力检查、测试和架构文档
This commit is contained in:
@@ -135,6 +135,8 @@ const requiredMainSnippets = [
|
||||
'tauri_plugin_clipboard_manager::init()',
|
||||
'"appearance.getColorScheme"',
|
||||
'"app.lifecycle"',
|
||||
'"network.status"',
|
||||
'"network.statusChanged"',
|
||||
'"share.open"',
|
||||
'"share.setTarget"',
|
||||
'"navigation.openNativePage"',
|
||||
@@ -152,6 +154,8 @@ const requiredMainSnippets = [
|
||||
'window.theme()',
|
||||
'WindowEvent::Focused',
|
||||
'host_bridge_event_script',
|
||||
'resolve_desktop_network_status',
|
||||
'network.statusChanged',
|
||||
];
|
||||
|
||||
for (const permission of requiredPermissions) {
|
||||
|
||||
@@ -2,8 +2,10 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tauri::Manager;
|
||||
use tauri::Theme;
|
||||
use tauri::Url;
|
||||
@@ -22,6 +24,7 @@ const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024;
|
||||
const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt";
|
||||
const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120;
|
||||
const BADGE_COUNT_MAX: i64 = 99999;
|
||||
const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -90,6 +93,8 @@ fn capabilities() -> Vec<&'static str> {
|
||||
"app.openExternalUrl",
|
||||
"app.setTitle",
|
||||
"app.setBadgeCount",
|
||||
"network.status",
|
||||
"network.statusChanged",
|
||||
"clipboard.writeText",
|
||||
"file.exportText",
|
||||
"file.exportImage",
|
||||
@@ -441,6 +446,34 @@ fn emit_desktop_lifecycle_event(
|
||||
window.eval(script)
|
||||
}
|
||||
|
||||
fn desktop_network_status_payload(is_online: bool) -> Value {
|
||||
json!({
|
||||
"isConnected": is_online,
|
||||
"isInternetReachable": is_online,
|
||||
"connectionType": if is_online { "unknown" } else { "none" },
|
||||
"nativeType": if is_online { "online" } else { "offline" },
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_desktop_network_status() -> Value {
|
||||
let timeout = Duration::from_millis(DESKTOP_NETWORK_CHECK_TIMEOUT_MS);
|
||||
let is_reachable = ("app.genarrative.world", 443)
|
||||
.to_socket_addrs()
|
||||
.map(|addresses| {
|
||||
addresses.into_iter().any(|address| {
|
||||
TcpStream::connect_timeout(&address, timeout)
|
||||
.map(|stream| {
|
||||
drop(stream);
|
||||
true
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
desktop_network_status_payload(is_reachable)
|
||||
}
|
||||
|
||||
fn register_desktop_lifecycle_events(window: &WebviewWindow) {
|
||||
let lifecycle_window = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
@@ -455,6 +488,56 @@ fn register_desktop_lifecycle_events(window: &WebviewWindow) {
|
||||
});
|
||||
}
|
||||
|
||||
fn register_desktop_network_events(window: &WebviewWindow) -> tauri::Result<()> {
|
||||
let online_script = host_bridge_event_script(
|
||||
"network.statusChanged",
|
||||
desktop_network_status_payload(true),
|
||||
)
|
||||
.map_err(tauri::Error::Json)?;
|
||||
let offline_script = host_bridge_event_script(
|
||||
"network.statusChanged",
|
||||
desktop_network_status_payload(false),
|
||||
)
|
||||
.map_err(tauri::Error::Json)?;
|
||||
let current_status_script = host_bridge_event_script(
|
||||
"network.statusChanged",
|
||||
json!({
|
||||
"isConnected": "__GENARRATIVE_DESKTOP_ONLINE__",
|
||||
"isInternetReachable": "__GENARRATIVE_DESKTOP_ONLINE__",
|
||||
"connectionType": "__GENARRATIVE_DESKTOP_CONNECTION_TYPE__",
|
||||
"nativeType": "__GENARRATIVE_DESKTOP_NATIVE_TYPE__",
|
||||
}),
|
||||
)
|
||||
.map_err(tauri::Error::Json)?
|
||||
.replace("\"__GENARRATIVE_DESKTOP_ONLINE__\"", "navigator.onLine")
|
||||
.replace(
|
||||
"\"__GENARRATIVE_DESKTOP_CONNECTION_TYPE__\"",
|
||||
"(navigator.onLine ? 'unknown' : 'none')",
|
||||
)
|
||||
.replace(
|
||||
"\"__GENARRATIVE_DESKTOP_NATIVE_TYPE__\"",
|
||||
"(navigator.onLine ? 'online' : 'offline')",
|
||||
);
|
||||
|
||||
let script = format!(
|
||||
"(() => {{
|
||||
if (window.__GENARRATIVE_DESKTOP_NETWORK_LISTENER_INSTALLED__) {{
|
||||
return true;
|
||||
}}
|
||||
window.__GENARRATIVE_DESKTOP_NETWORK_LISTENER_INSTALLED__ = true;
|
||||
const emitOnline = () => {{ {} }};
|
||||
const emitOffline = () => {{ {} }};
|
||||
window.addEventListener('online', emitOnline);
|
||||
window.addEventListener('offline', emitOffline);
|
||||
{}
|
||||
return true;
|
||||
}})();",
|
||||
online_script, offline_script, current_status_script
|
||||
);
|
||||
|
||||
window.eval(script)
|
||||
}
|
||||
|
||||
fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
|
||||
value
|
||||
.get(field)
|
||||
@@ -730,6 +813,14 @@ async fn host_bridge_request(
|
||||
None => failed(request.id, "host_error", "main window not found"),
|
||||
}
|
||||
}
|
||||
"network.status" => {
|
||||
let network_status =
|
||||
tauri::async_runtime::spawn_blocking(resolve_desktop_network_status).await;
|
||||
match network_status {
|
||||
Ok(status) => ok(request.id, status),
|
||||
Err(error) => failed(request.id, "host_error", error.to_string()),
|
||||
}
|
||||
}
|
||||
"share.setTarget" => {
|
||||
let target = request
|
||||
.payload
|
||||
@@ -783,6 +874,7 @@ fn main() {
|
||||
tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?.build()?;
|
||||
register_desktop_lifecycle_events(&window);
|
||||
let _ = emit_desktop_lifecycle_event(&window, "active", true, "created");
|
||||
let _ = register_desktop_network_events(&window);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -822,6 +914,14 @@ mod tests {
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("app.lifecycle")));
|
||||
assert!(result["capabilities"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("network.status")));
|
||||
assert!(result["capabilities"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("network.statusChanged")));
|
||||
assert!(result["capabilities"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
@@ -911,6 +1011,28 @@ mod tests {
|
||||
assert!(script.contains("\\\"focused\\\":true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_network_status_payload_reports_reachability() {
|
||||
assert_eq!(
|
||||
desktop_network_status_payload(true),
|
||||
json!({
|
||||
"isConnected": true,
|
||||
"isInternetReachable": true,
|
||||
"connectionType": "unknown",
|
||||
"nativeType": "online",
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
desktop_network_status_payload(false),
|
||||
json!({
|
||||
"isConnected": false,
|
||||
"isInternetReachable": false,
|
||||
"connectionType": "none",
|
||||
"nativeType": "offline",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_url_normalization_allows_only_safe_protocols() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"build": {
|
||||
"beforeDevCommand": "npm --prefix ../.. run dev:web",
|
||||
"beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck",
|
||||
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,clipboard.writeText,file.exportText,file.exportImage",
|
||||
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,file.exportText,file.exportImage",
|
||||
"frontendDist": "../../../dist"
|
||||
},
|
||||
"app": {
|
||||
@@ -14,7 +14,7 @@
|
||||
{
|
||||
"create": false,
|
||||
"label": "main",
|
||||
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,clipboard.writeText,file.exportText,file.exportImage",
|
||||
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,file.exportText,file.exportImage",
|
||||
"title": "Genarrative",
|
||||
"width": 1280,
|
||||
"height": 820,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
resolveMobileShellExternalUrl,
|
||||
shouldOpenInMobileShellWebView,
|
||||
} from './src/mobileShellNavigation';
|
||||
import { subscribeMobileNetworkStatus } from './src/mobileShellNetwork';
|
||||
import { buildMobileShellUrl } from './src/mobileShellUrl';
|
||||
|
||||
const defaultWebUrl = 'http://127.0.0.1:3000/';
|
||||
@@ -127,6 +128,19 @@ export default function App() {
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeMobileNetworkStatus((payload) => {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
buildHostBridgeMessageScript({
|
||||
bridge: 'GenarrativeHostBridge',
|
||||
version: 1,
|
||||
event: 'network.statusChanged',
|
||||
payload,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMessage = (event: WebViewMessageEvent) => {
|
||||
void handleMobileHostBridgeMessage(event.nativeEvent.data, (response) => {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"expo-file-system": "^56.0.8",
|
||||
"expo-haptics": "^56.0.3",
|
||||
"expo-linking": "^56.0.14",
|
||||
"expo-network": "^56.0.5",
|
||||
"expo-sharing": "^56.0.18",
|
||||
"expo-status-bar": "^56.0.4",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -119,6 +119,8 @@ for (const snippet of [
|
||||
'configureMobileHostBridgeNavigation',
|
||||
'AppState.addEventListener',
|
||||
'app.lifecycle',
|
||||
'network.statusChanged',
|
||||
'subscribeMobileNetworkStatus',
|
||||
'navigation.canGoBack',
|
||||
'buildHostBridgeMessageScript',
|
||||
]) {
|
||||
@@ -127,7 +129,7 @@ for (const snippet of [
|
||||
}
|
||||
}
|
||||
|
||||
for (const dependency of ['expo-file-system', 'expo-sharing']) {
|
||||
for (const dependency of ['expo-file-system', 'expo-network', 'expo-sharing']) {
|
||||
if (!packageConfig.dependencies?.[dependency]) {
|
||||
throw new Error(`mobile shell package missing ${dependency}`);
|
||||
}
|
||||
@@ -136,6 +138,8 @@ for (const dependency of ['expo-file-system', 'expo-sharing']) {
|
||||
for (const snippet of [
|
||||
'file.exportText',
|
||||
'file.exportImage',
|
||||
'network.status',
|
||||
'getMobileNetworkStatus',
|
||||
'Sharing.shareAsync',
|
||||
'normalizeHostBridgeExportFileName',
|
||||
'base64Data',
|
||||
@@ -160,6 +164,8 @@ for (const capability of [
|
||||
'share.open',
|
||||
'share.setTarget',
|
||||
'app.lifecycle',
|
||||
'network.status',
|
||||
'network.statusChanged',
|
||||
'navigation.openNativePage',
|
||||
'navigation.canGoBack',
|
||||
'app.openExternalUrl',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as Network from 'expo-network';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import {
|
||||
Appearance,
|
||||
@@ -69,6 +70,20 @@ vi.mock('expo-linking', () => ({
|
||||
openURL: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('expo-network', () => ({
|
||||
getNetworkStateAsync: vi.fn(async () => ({
|
||||
type: 'WIFI',
|
||||
isConnected: true,
|
||||
isInternetReachable: true,
|
||||
})),
|
||||
NetworkStateType: {
|
||||
CELLULAR: 'CELLULAR',
|
||||
ETHERNET: 'ETHERNET',
|
||||
NONE: 'NONE',
|
||||
WIFI: 'WIFI',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('expo-sharing', () => ({
|
||||
isAvailableAsync: vi.fn(async () => true),
|
||||
shareAsync: vi.fn(),
|
||||
@@ -142,6 +157,12 @@ afterEach(() => {
|
||||
vi.mocked(Appearance.getColorScheme).mockReturnValue('light');
|
||||
vi.mocked(Haptics.impactAsync).mockReset();
|
||||
vi.mocked(Linking.openURL).mockReset();
|
||||
vi.mocked(Network.getNetworkStateAsync).mockReset();
|
||||
vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({
|
||||
type: Network.NetworkStateType.WIFI,
|
||||
isConnected: true,
|
||||
isInternetReachable: true,
|
||||
});
|
||||
vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber).mockReset();
|
||||
setPlatformOS('ios');
|
||||
vi.mocked(Sharing.isAvailableAsync).mockReset();
|
||||
@@ -175,6 +196,8 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
'appearance.getColorScheme',
|
||||
'host.events',
|
||||
'app.lifecycle',
|
||||
'network.status',
|
||||
'network.statusChanged',
|
||||
'navigation.canGoBack',
|
||||
'app.setBadgeCount',
|
||||
]),
|
||||
@@ -288,6 +311,23 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
expect(Linking.openURL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('network.status 返回 Expo Network 真实状态', async () => {
|
||||
vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({
|
||||
type: Network.NetworkStateType.CELLULAR,
|
||||
isConnected: true,
|
||||
isInternetReachable: false,
|
||||
});
|
||||
|
||||
const response = await send(request('network.status'));
|
||||
|
||||
expect(expectOk(response).result).toEqual({
|
||||
isConnected: true,
|
||||
isInternetReachable: false,
|
||||
connectionType: 'cellular',
|
||||
nativeType: 'CELLULAR',
|
||||
});
|
||||
});
|
||||
|
||||
test('haptics.impact 调起 Expo 触觉反馈', async () => {
|
||||
const response = await send(
|
||||
request('haptics.impact', {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type ShareOpenPayload,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
import { resolveMobileShellWebViewUrl } from './mobileShellNavigation';
|
||||
import { getMobileNetworkStatus } from './mobileShellNetwork';
|
||||
|
||||
const WEB_APP_ORIGIN = 'https://app.genarrative.world';
|
||||
const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
@@ -54,6 +55,8 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
||||
'navigation.openNativePage',
|
||||
'navigation.canGoBack',
|
||||
'app.openExternalUrl',
|
||||
'network.status',
|
||||
'network.statusChanged',
|
||||
'clipboard.writeText',
|
||||
'file.exportText',
|
||||
'file.exportImage',
|
||||
@@ -437,6 +440,8 @@ async function handleRequest(request: HostBridgeRequest) {
|
||||
return ok(request, getColorScheme());
|
||||
case 'app.openExternalUrl':
|
||||
return ok(request, await openExternalUrl(request.payload));
|
||||
case 'network.status':
|
||||
return ok(request, await getMobileNetworkStatus());
|
||||
case 'clipboard.writeText':
|
||||
return ok(request, await writeClipboard(request.payload));
|
||||
case 'file.exportText':
|
||||
|
||||
84
apps/mobile-shell/src/mobileShellNetwork.test.ts
Normal file
84
apps/mobile-shell/src/mobileShellNetwork.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getMobileNetworkStatus,
|
||||
normalizeMobileNetworkStatus,
|
||||
subscribeMobileNetworkStatus,
|
||||
} from './mobileShellNetwork';
|
||||
|
||||
vi.mock('expo-network', () => ({
|
||||
addNetworkStateListener: vi.fn(),
|
||||
getNetworkStateAsync: vi.fn(),
|
||||
NetworkStateType: {
|
||||
CELLULAR: 'CELLULAR',
|
||||
ETHERNET: 'ETHERNET',
|
||||
NONE: 'NONE',
|
||||
WIFI: 'WIFI',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('mobileShellNetwork', async () => {
|
||||
const Network = await import('expo-network');
|
||||
|
||||
test('归一化 Expo Network 状态', () => {
|
||||
expect(
|
||||
normalizeMobileNetworkStatus({
|
||||
type: Network.NetworkStateType.WIFI,
|
||||
isConnected: true,
|
||||
isInternetReachable: true,
|
||||
}),
|
||||
).toEqual({
|
||||
isConnected: true,
|
||||
isInternetReachable: true,
|
||||
connectionType: 'wifi',
|
||||
nativeType: 'WIFI',
|
||||
});
|
||||
|
||||
expect(
|
||||
normalizeMobileNetworkStatus({
|
||||
type: Network.NetworkStateType.NONE,
|
||||
}),
|
||||
).toEqual({
|
||||
isConnected: false,
|
||||
isInternetReachable: null,
|
||||
connectionType: 'none',
|
||||
nativeType: 'NONE',
|
||||
});
|
||||
});
|
||||
|
||||
test('查询和订阅真实 Expo Network API', async () => {
|
||||
vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({
|
||||
type: Network.NetworkStateType.CELLULAR,
|
||||
isConnected: true,
|
||||
isInternetReachable: false,
|
||||
});
|
||||
const remove = vi.fn();
|
||||
vi.mocked(Network.addNetworkStateListener).mockReturnValue({ remove });
|
||||
const listener = vi.fn();
|
||||
|
||||
await expect(getMobileNetworkStatus()).resolves.toEqual({
|
||||
isConnected: true,
|
||||
isInternetReachable: false,
|
||||
connectionType: 'cellular',
|
||||
nativeType: 'CELLULAR',
|
||||
});
|
||||
|
||||
const unsubscribe = subscribeMobileNetworkStatus(listener);
|
||||
const networkListener = vi.mocked(Network.addNetworkStateListener).mock
|
||||
.calls[0]?.[0];
|
||||
networkListener?.({
|
||||
type: Network.NetworkStateType.ETHERNET,
|
||||
isConnected: true,
|
||||
isInternetReachable: true,
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
expect(listener).toHaveBeenCalledWith({
|
||||
isConnected: true,
|
||||
isInternetReachable: true,
|
||||
connectionType: 'ethernet',
|
||||
nativeType: 'ETHERNET',
|
||||
});
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
40
apps/mobile-shell/src/mobileShellNetwork.ts
Normal file
40
apps/mobile-shell/src/mobileShellNetwork.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as Network from 'expo-network';
|
||||
|
||||
import {
|
||||
type NetworkStatusResult,
|
||||
normalizeHostBridgeConnectionType,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
|
||||
export function normalizeMobileNetworkStatus(
|
||||
state: Network.NetworkState,
|
||||
): NetworkStatusResult {
|
||||
const nativeType = state.type;
|
||||
const connectionType = normalizeHostBridgeConnectionType(nativeType);
|
||||
|
||||
return {
|
||||
isConnected:
|
||||
typeof state.isConnected === 'boolean'
|
||||
? state.isConnected
|
||||
: connectionType !== 'none' && connectionType !== 'unknown',
|
||||
isInternetReachable:
|
||||
typeof state.isInternetReachable === 'boolean'
|
||||
? state.isInternetReachable
|
||||
: null,
|
||||
connectionType,
|
||||
...(nativeType ? { nativeType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMobileNetworkStatus() {
|
||||
return normalizeMobileNetworkStatus(await Network.getNetworkStateAsync());
|
||||
}
|
||||
|
||||
export function subscribeMobileNetworkStatus(
|
||||
listener: (status: NetworkStatusResult) => void,
|
||||
) {
|
||||
const subscription = Network.addNetworkStateListener((state) => {
|
||||
listener(normalizeMobileNetworkStatus(state));
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}
|
||||
Reference in New Issue
Block a user