516 lines
18 KiB
TypeScript
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,
|
|
},
|
|
});
|