The separate runtime MOCK switch on the map screen is removed. Now the admin-only settings toggle (MOCK_API_FEATURE_ENABLED) is the single control point — turning it on activates mock mode immediately, turning it off deactivates it. - useTrainMenu: remove mockApiEnabled/setMockApiEnabled state; mockApiConfig now derives from mockApiFeatureEnabled alone - WebView: use mockApiFeatureEnabled for key prop (triggers reload) - Apps.tsx: remove MockApiToggle import and JSX usage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
import React from "react";
|
|
import { Alert, ActivityIndicator, AppState, AppStateStatus, BackHandler, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
|
import { WebView } from "react-native-webview";
|
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
|
import { BigButton } from "./components/atom/BigButton";
|
|
import { useFocusEffect, useNavigation } from "@react-navigation/native";
|
|
import { useThemeColors } from "@/lib/theme";
|
|
import { AS } from "./storageControl";
|
|
import { STORAGE_KEYS } from "@/constants";
|
|
import {
|
|
DEFAULT_JR_DATA_SYSTEM_ENV,
|
|
normalizeJrDataSystemEnvironment,
|
|
rewriteJrDataSystemUrl,
|
|
} from "@/lib/jrDataSystemEnvironment";
|
|
|
|
export default ({ route }) => {
|
|
if (!route.params) {
|
|
return null;
|
|
}
|
|
const { uri, useExitButton = true } = route.params;
|
|
const { goBack } = useNavigation();
|
|
const { fixed } = useThemeColors();
|
|
const webViewRef = React.useRef<WebView>(null);
|
|
const [canGoBack, setCanGoBack] = React.useState(false);
|
|
const [selectedEnvironment, setSelectedEnvironment] = React.useState(
|
|
DEFAULT_JR_DATA_SYSTEM_ENV,
|
|
);
|
|
const [resolvedUri, setResolvedUri] = React.useState("");
|
|
const [isEnvironmentReady, setIsEnvironmentReady] = React.useState(false);
|
|
const hasAlerted = React.useRef(false);
|
|
const [isLoading, setIsLoading] = React.useState(true);
|
|
const [hasError, setHasError] = React.useState(false);
|
|
const [errorMessage, setErrorMessage] = React.useState("");
|
|
// WebViewをforce remountするためのkey
|
|
const [webViewKey, setWebViewKey] = React.useState(0);
|
|
// バックグラウンド移行時刻の記録
|
|
const backgroundedAt = React.useRef<number | null>(null);
|
|
// RN-side watchdog: WebViewプロセスの死活確認用
|
|
const lastPongAt = React.useRef<number>(Date.now());
|
|
const isLoadingRef = React.useRef(true);
|
|
const hasErrorRef = React.useRef(false);
|
|
// コンテンツ消失検知用: bodyLen の最大値と連続白画面カウント
|
|
const maxBodyLenRef = React.useRef(0);
|
|
const blankCountRef = React.useRef(0);
|
|
|
|
const remount = React.useCallback(() => {
|
|
lastPongAt.current = Date.now(); // remount直後に誤検知しないようリセット
|
|
maxBodyLenRef.current = 0;
|
|
blankCountRef.current = 0;
|
|
setHasError(false);
|
|
hasErrorRef.current = false;
|
|
setIsLoading(true);
|
|
isLoadingRef.current = true;
|
|
setWebViewKey((k) => k + 1);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const applyEnvironment = (value: unknown) => {
|
|
if (!isMounted) return;
|
|
const nextEnvironment = normalizeJrDataSystemEnvironment(value);
|
|
setSelectedEnvironment(nextEnvironment);
|
|
setResolvedUri(
|
|
rewriteJrDataSystemUrl(
|
|
typeof uri === "string" ? uri : "",
|
|
nextEnvironment,
|
|
),
|
|
);
|
|
setIsEnvironmentReady(true);
|
|
};
|
|
|
|
AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV)
|
|
.then(applyEnvironment)
|
|
.catch(() => applyEnvironment(DEFAULT_JR_DATA_SYSTEM_ENV));
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [uri]);
|
|
|
|
const handleReload = () => {
|
|
lastPongAt.current = Date.now();
|
|
setHasError(false);
|
|
hasErrorRef.current = false;
|
|
setIsLoading(true);
|
|
isLoadingRef.current = true;
|
|
setWebViewKey((k) => k + 1);
|
|
};
|
|
|
|
// AppState監視: バックグラウンド10秒超で復帰したらWebViewを再マウント
|
|
React.useEffect(() => {
|
|
const onAppStateChange = (nextState: AppStateStatus) => {
|
|
if (nextState.match(/inactive|background/)) {
|
|
backgroundedAt.current = Date.now();
|
|
} else if (nextState === "active" && backgroundedAt.current !== null) {
|
|
const elapsed = Date.now() - backgroundedAt.current;
|
|
backgroundedAt.current = null;
|
|
if (elapsed > 10_000) {
|
|
remount();
|
|
}
|
|
}
|
|
};
|
|
const subscription = AppState.addEventListener("change", onAppStateChange);
|
|
return () => subscription.remove();
|
|
}, [remount]);
|
|
|
|
useFocusEffect(
|
|
React.useCallback(() => {
|
|
const onHardwareBack = () => {
|
|
if (canGoBack) {
|
|
webViewRef.current?.goBack();
|
|
return true;
|
|
}
|
|
goBack();
|
|
return true;
|
|
};
|
|
|
|
const subscription = BackHandler.addEventListener("hardwareBackPress", onHardwareBack);
|
|
return () => subscription.remove();
|
|
}, [canGoBack, goBack])
|
|
);
|
|
return (
|
|
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
|
|
{isEnvironmentReady && (
|
|
<WebView
|
|
key={webViewKey}
|
|
source={{ uri: resolvedUri }}
|
|
contentMode="mobile"
|
|
allowsBackForwardNavigationGestures
|
|
ref={webViewRef}
|
|
onLoadStart={() => {
|
|
isLoadingRef.current = true;
|
|
setHasError(false);
|
|
hasErrorRef.current = false;
|
|
maxBodyLenRef.current = 0;
|
|
blankCountRef.current = 0;
|
|
}}
|
|
onLoadEnd={() => {
|
|
setIsLoading(false);
|
|
isLoadingRef.current = false;
|
|
lastPongAt.current = Date.now();
|
|
}}
|
|
onError={(syntheticEvent) => {
|
|
const { nativeEvent } = syntheticEvent;
|
|
setIsLoading(false);
|
|
isLoadingRef.current = false;
|
|
setHasError(true);
|
|
hasErrorRef.current = true;
|
|
setErrorMessage(nativeEvent.description || "ページを読み込めませんでした");
|
|
}}
|
|
onHttpError={(syntheticEvent) => {
|
|
const { nativeEvent } = syntheticEvent;
|
|
if (nativeEvent.statusCode >= 500) {
|
|
setIsLoading(false);
|
|
isLoadingRef.current = false;
|
|
setHasError(true);
|
|
hasErrorRef.current = true;
|
|
setErrorMessage(`サーバーエラー (${nativeEvent.statusCode})`);
|
|
}
|
|
}}
|
|
onRenderProcessGone={() => {
|
|
// クラッシュ・メモリ回収どちらも自動remount
|
|
remount();
|
|
}}
|
|
// iOS: コンテンツプロセスがメモリ圧迫で終了した場合
|
|
onContentProcessDidTerminate={() => remount()}
|
|
onShouldStartLoadWithRequest={(request) => {
|
|
if (request.isTopFrame === false) {
|
|
return true;
|
|
}
|
|
|
|
const rewrittenUrl = rewriteJrDataSystemUrl(
|
|
request.url,
|
|
selectedEnvironment,
|
|
);
|
|
if (rewrittenUrl !== request.url) {
|
|
setResolvedUri(rewrittenUrl);
|
|
return false;
|
|
}
|
|
return true;
|
|
}}
|
|
onNavigationStateChange={(navState) => {
|
|
setCanGoBack(navState.canGoBack);
|
|
// SPA内遷移中は白画面誤検知を防ぐためblankCountをリセット
|
|
if (navState.loading) blankCountRef.current = 0;
|
|
if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") {
|
|
webViewRef.current?.goBack();
|
|
if (!hasAlerted.current) {
|
|
hasAlerted.current = true;
|
|
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
|
|
{ text: "完了" },
|
|
]);
|
|
}
|
|
}
|
|
}}
|
|
onMessage={(event) => {
|
|
const { data } = event.nativeEvent;
|
|
const parsed = JSON.parse(data);
|
|
const { type } = parsed;
|
|
if (type === "pong") {
|
|
lastPongAt.current = Date.now();
|
|
const bodyLen: number = parsed.bodyLen ?? 0;
|
|
// innerTextベース: 最大値を更新
|
|
if (bodyLen > maxBodyLenRef.current) maxBodyLenRef.current = bodyLen;
|
|
// 一度200文字超の表示テキストがあった後に20文字未満になったら白画面と判定
|
|
// SPA遷移中の一時的な空白を避けるため3回連続(15秒)で発火
|
|
if (maxBodyLenRef.current > 200 && bodyLen < 20) {
|
|
blankCountRef.current += 1;
|
|
if (blankCountRef.current >= 3) {
|
|
blankCountRef.current = 0;
|
|
maxBodyLenRef.current = 0;
|
|
remount();
|
|
}
|
|
} else {
|
|
blankCountRef.current = 0;
|
|
}
|
|
return;
|
|
}
|
|
if (type === "back") return webViewRef.current?.goBack();
|
|
if (type === "windowClose") return goBack();
|
|
}}
|
|
/>
|
|
)}
|
|
{isLoading && !hasError && (
|
|
<View style={wvStyles.loadingOverlay} pointerEvents="none">
|
|
<ActivityIndicator size="large" color="#fff" />
|
|
</View>
|
|
)}
|
|
{hasError && (
|
|
<View style={wvStyles.errorOverlay}>
|
|
<MaterialCommunityIcons name="wifi-off" size={48} color="#ccc" />
|
|
<Text style={wvStyles.errorText}>{errorMessage}</Text>
|
|
<TouchableOpacity style={wvStyles.reloadButton} onPress={handleReload}>
|
|
<MaterialCommunityIcons name="reload" size={18} color="#fff" />
|
|
<Text style={wvStyles.reloadButtonText}>再読み込み</Text>
|
|
</TouchableOpacity>
|
|
{useExitButton && (
|
|
<TouchableOpacity style={wvStyles.backButton} onPress={goBack}>
|
|
<Text style={wvStyles.backButtonText}>閉じる</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
)}
|
|
{useExitButton && !hasError && <BigButton onPress={goBack} string="閉じる" />}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const wvStyles = StyleSheet.create({
|
|
loadingOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
backgroundColor: "rgba(0,0,0,0.25)",
|
|
},
|
|
errorOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
backgroundColor: "#1a1a2e",
|
|
gap: 16,
|
|
paddingHorizontal: 32,
|
|
},
|
|
errorText: {
|
|
color: "#aaa",
|
|
fontSize: 14,
|
|
textAlign: "center",
|
|
},
|
|
reloadButton: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
backgroundColor: "#0099CC",
|
|
borderRadius: 10,
|
|
paddingHorizontal: 24,
|
|
paddingVertical: 12,
|
|
},
|
|
reloadButtonText: {
|
|
color: "#fff",
|
|
fontSize: 15,
|
|
fontWeight: "bold",
|
|
},
|
|
backButton: {
|
|
paddingHorizontal: 24,
|
|
paddingVertical: 10,
|
|
},
|
|
backButtonText: {
|
|
color: "#888",
|
|
fontSize: 14,
|
|
},
|
|
});
|