Files
jrshikoku/GeneralWebView.tsx

516 lines
18 KiB
TypeScript

import React from "react";
import { Alert, ActivityIndicator, BackHandler, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import * as FileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing";
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 { useTrainMenu } from "@/stateBox/useTrainMenu";
import {
DEFAULT_JR_DATA_SYSTEM_ENV,
normalizeJrDataSystemEnvironment,
rewriteJrDataSystemUrl,
} from "@/lib/jrDataSystemEnvironment";
const RECORDING_DOWNLOAD_BRIDGE_SCRIPT = `
(() => {
if (window.__JRS_RECORDING_DOWNLOAD_BRIDGE__) return true;
window.__JRS_RECORDING_DOWNLOAD_BRIDGE__ = true;
const blobUrls = new Map();
const post = (payload) => {
window.ReactNativeWebView?.postMessage(JSON.stringify(payload));
};
const isLikelyRecordingDownload = (url, anchor) => {
if (!url) return false;
const lowerUrl = String(url).toLowerCase();
const downloadName = anchor?.getAttribute?.('download') || '';
const lowerName = String(downloadName).toLowerCase();
return Boolean(downloadName)
|| lowerUrl.includes('recording')
|| lowerUrl.includes('recordings')
|| lowerUrl.includes('download')
|| lowerUrl.includes('export')
|| lowerUrl.includes('json')
|| lowerName.includes('recording')
|| lowerName.endsWith('.json');
};
const readUrlAsText = async (url) => {
if (blobUrls.has(url)) {
return blobUrls.get(url).text();
}
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.text();
};
const importFromUrl = async (url) => {
try {
const text = await readUrlAsText(url);
post({ type: 'importRecordingDownload', text, sourceUrl: url });
} catch (error) {
post({
type: 'importRecordingDownloadError',
message: error?.message || String(error),
sourceUrl: url,
});
}
};
const findAnchor = (target) => {
let node = target;
while (node && node !== document) {
if (node.tagName === 'A' && node.href) return node;
node = node.parentNode;
}
return null;
};
if (window.URL?.createObjectURL) {
const originalCreateObjectURL = window.URL.createObjectURL.bind(window.URL);
window.URL.createObjectURL = (object) => {
const url = originalCreateObjectURL(object);
if (object instanceof Blob) blobUrls.set(url, object);
return url;
};
}
document.addEventListener('click', (event) => {
const anchor = findAnchor(event.target);
if (!anchor || !isLikelyRecordingDownload(anchor.href, anchor)) return;
event.preventDefault();
event.stopPropagation();
importFromUrl(anchor.href);
}, true);
if (window.HTMLAnchorElement?.prototype?.click) {
const originalClick = window.HTMLAnchorElement.prototype.click;
window.HTMLAnchorElement.prototype.click = function patchedClick() {
if (isLikelyRecordingDownload(this.href, this)) {
importFromUrl(this.href);
return;
}
return originalClick.apply(this, arguments);
};
}
return true;
})();
true;
`;
const isLikelyRecordingDownloadUrl = (url: string) => {
const lowerUrl = url.toLowerCase();
return lowerUrl.includes('recording')
|| lowerUrl.includes('recordings')
|| lowerUrl.includes('download')
|| lowerUrl.includes('export')
|| lowerUrl.includes('json')
|| lowerUrl.endsWith('.json');
};
export default ({ route }) => {
if (!route.params) {
return null;
}
const {
uri,
useExitButton = true,
importRecordingDownloads = false,
} = route.params;
const { goBack } = useNavigation();
const { fixed } = useThemeColors();
const { importRecordingsFromText } = useTrainMenu();
const webViewRef = React.useRef<WebView>(null);
const [canGoBack, setCanGoBack] = React.useState(false);
const nativeCanGoBackRef = React.useRef(false);
const historyStackRef = React.useRef<string[]>([]);
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);
// 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);
const rawUri = typeof uri === "string" ? uri : "";
historyStackRef.current = [];
nativeCanGoBackRef.current = false;
setCanGoBack(false);
setSelectedEnvironment(nextEnvironment);
setResolvedUri(
importRecordingDownloads
? rawUri
: rewriteJrDataSystemUrl(rawUri, nextEnvironment),
);
setIsEnvironmentReady(true);
};
AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV)
.then(applyEnvironment)
.catch(() => applyEnvironment(DEFAULT_JR_DATA_SYSTEM_ENV));
return () => {
isMounted = false;
};
}, [uri, importRecordingDownloads]);
const handleReload = () => {
lastPongAt.current = Date.now();
setHasError(false);
hasErrorRef.current = false;
setIsLoading(true);
isLoadingRef.current = true;
setWebViewKey((k) => k + 1);
};
const syncCanGoBack = React.useCallback((nativeCanGoBack: boolean) => {
nativeCanGoBackRef.current = nativeCanGoBack;
setCanGoBack(nativeCanGoBack || historyStackRef.current.length > 1);
}, []);
const pushHistoryEntry = React.useCallback((url: string) => {
if (!url) return;
const stack = historyStackRef.current;
if (stack[stack.length - 1] === url) return;
stack.push(url);
if (stack.length > 30) {
stack.splice(0, stack.length - 30);
}
}, []);
const handlePseudoBack = React.useCallback(() => {
const stack = historyStackRef.current;
if (stack.length <= 1) return false;
stack.pop();
const previousUrl = stack[stack.length - 1];
if (!previousUrl) return false;
setResolvedUri(previousUrl);
syncCanGoBack(false);
remount();
return true;
}, [remount, syncCanGoBack]);
const handleWebViewBack = React.useCallback(() => {
if (nativeCanGoBackRef.current) {
webViewRef.current?.goBack();
return true;
}
return handlePseudoBack();
}, [handlePseudoBack]);
const handleImportRecordingText = React.useCallback(
async (content: string) => {
try {
const result = await importRecordingsFromText(content);
Alert.alert(
"録画データを読み込みました",
result.overwrittenCount > 0
? `${result.importedCount}件を読み込みました。${result.overwrittenCount}件は同じIDのため上書きしました。`
: `${result.importedCount}件を読み込みました。`,
);
} catch (error) {
Alert.alert(
"録画データを読み込めませんでした",
(error as Error).message || "ファイル内容を確認してください。",
);
}
},
[importRecordingsFromText],
);
const handleImportRecordingUrl = React.useCallback(
async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
await handleImportRecordingText(await response.text());
} catch (error) {
Alert.alert(
"録画データを取得できませんでした",
(error as Error).message || "通信状態を確認してください。",
);
}
},
[handleImportRecordingText],
);
useFocusEffect(
React.useCallback(() => {
const onHardwareBack = () => {
if (canGoBack) {
if (handleWebViewBack()) return true;
}
goBack();
return true;
};
const subscription = BackHandler.addEventListener("hardwareBackPress", onHardwareBack);
return () => subscription.remove();
}, [canGoBack, goBack, handleWebViewBack])
);
return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
{isEnvironmentReady && (
<WebView
key={webViewKey}
source={{ uri: resolvedUri }}
contentMode="mobile"
allowsBackForwardNavigationGestures
setSupportMultipleWindows={false}
ref={webViewRef}
injectedJavaScriptBeforeContentLoaded={
importRecordingDownloads ? RECORDING_DOWNLOAD_BRIDGE_SCRIPT : `true;`
}
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;
}
if (
importRecordingDownloads &&
isLikelyRecordingDownloadUrl(request.url) &&
request.url !== resolvedUri
) {
void handleImportRecordingUrl(request.url);
return false;
}
if (!importRecordingDownloads) {
const rewrittenUrl = rewriteJrDataSystemUrl(
request.url,
selectedEnvironment,
);
if (rewrittenUrl !== request.url) {
setResolvedUri(rewrittenUrl);
return false;
}
}
return true;
}}
onFileDownload={(event) => {
if (!importRecordingDownloads) return;
const downloadUrl = event.nativeEvent.downloadUrl;
if (downloadUrl) {
void handleImportRecordingUrl(downloadUrl);
}
}}
onNavigationStateChange={(navState) => {
if (navState.url) {
const nextUrl = importRecordingDownloads
? navState.url
: rewriteJrDataSystemUrl(navState.url, selectedEnvironment);
setResolvedUri((current) => (current === nextUrl ? current : nextUrl));
if (nextUrl !== "https://unyohub.2pd.jp/integration/succeeded.php") {
pushHistoryEntry(nextUrl);
}
}
syncCanGoBack(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;
let parsed: any;
try {
parsed = JSON.parse(data);
} catch {
return;
}
const { type } = parsed;
if (type === "importRecordingDownload") {
void handleImportRecordingText(String(parsed.text ?? ""));
return;
}
if (type === "importRecordingDownloadError") {
Alert.alert(
"録画データを取得できませんでした",
parsed.message || "ダウンロード内容を読み取れませんでした。",
);
return;
}
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 === "printHtml") {
(async () => {
try {
const path = FileSystem.cacheDirectory + "diagram.html";
await FileSystem.writeAsStringAsync(path, parsed.html, { encoding: FileSystem.EncodingType.UTF8 });
const ok = await Sharing.isAvailableAsync();
if (ok) {
await Sharing.shareAsync(path, { mimeType: "text/html", dialogTitle: "ダイヤグラムを共有" });
}
} catch (e) {
Alert.alert("エラー", "PDF出力の準備に失敗しました。");
}
})();
return;
}
if (type === "back") return handleWebViewBack();
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,
},
});