Compare commits

...

12 Commits

Author SHA1 Message Date
harukin-expo-dev-env 9ea9d6764b Adjust WebView remount behavior for background tabs 2026-07-03 10:24:41 +00:00
harukin-expo-dev-env cf3995fcbe factor code structure for improved readability and maintainability 2026-07-02 02:37:34 +00:00
harukin-expo-dev-env 983c453322 Merge commit 'c69585b257d234fbc640839c851ed3d57cfebd63' into develop 2026-07-01 12:10:45 +00:00
harukin-expo-dev-env c69585b257 インポート機能の作成、録画システムのポジション最適化 2026-06-29 16:11:42 +00:00
harukin-expo-dev-env c6d4fca881 Merge commit '9c6719050935e76e3618ece27b9b838b5efe05e8' into develop 2026-06-29 14:43:20 +00:00
harukin-expo-dev-env 9c67190509 Update signage view configuration 2026-06-29 14:42:44 +00:00
harukin-expo-dev-env 956b660b4f Add recording JSON import and export 2026-06-29 14:39:30 +00:00
harukin-expo-dev-env f056fe593a Sentryロギングシステムを追加し、アプリのエラートラッキングを強化 2026-06-29 06:08:29 +00:00
harukin-expo-dev-env ca8d3225d1 Merge commit '0ebc6bc153d9893c7d067dd31805d2b87e3a3e9b' into develop 2026-06-27 04:37:27 +00:00
harukin-expo-dev-env 0ebc6bc153 Merge commit 'fbd342d640a13972e92a7ed35c061d42ca9f5a75' into patch/6.x 2026-06-27 04:35:48 +00:00
harukin-expo-dev-env fbd342d640 要素のサイズ変更 2026-06-27 04:33:54 +00:00
harukin-expo-dev-env b6e6d2e809 Merge commit '9eff185351a96859f9fb06c08b073c7b3e5cefa3' into develop 2026-06-25 17:46:42 +00:00
30 changed files with 5144 additions and 2248 deletions
+2 -1
View File
@@ -59,4 +59,5 @@ ios/
!modules/**/ios/
*.ipa
*.apk
*.aab
*.aab
.env.local
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"Sentry": {
"url": "https://mcp.sentry.dev/mcp/xprocess-m5/jr-shikoku-unofficial-apps"
}
}
}
+22 -2
View File
@@ -28,6 +28,26 @@ import { UserPositionProvider } from "./stateBox/useUserPosition";
import { rootNavigationRef, stackAwareNavigate } from "./lib/rootNavigation";
import { AppThemeProvider } from "./lib/theme";
import StatusbarDetect from "./StatusbarDetect";
import * as Sentry from '@sentry/react-native';
Sentry.init({
dsn: 'https://1090312e4cf501f5a455d523eff2d538@o4511646874664960.ingest.us.sentry.io/4511646880432128',
// Adds more context data to events (IP address, cookies, user, etc.)
// For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
sendDefaultPii: true,
// Enable Logs
enableLogs: true,
// Configure Session Replay
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1,
integrations: [Sentry.mobileReplayIntegration(), Sentry.feedbackIntegration()],
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
// spotlight: __DEV__,
});
LogBox.ignoreLogs([
"ViewPropTypes will be removed",
@@ -40,7 +60,7 @@ if (Platform.OS === "android") {
}
}
export default function App() {
export default Sentry.wrap(function App() {
useEffect(() => {
UpdateAsync();
}, []);
@@ -157,7 +177,7 @@ export default function App() {
</DeviceOrientationChangeProvider>
</AppThemeProvider>
);
}
});
/**
* 低密度ディスプレイ(DeX等)で全体を transform scale で拡大。
+238 -6
View File
@@ -1,7 +1,7 @@
import React from "react";
import { NavigationContainer, DarkTheme, DefaultTheme } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions } from "react-native";
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions, InteractionManager } from "react-native";
import { useNavigationState } from "@react-navigation/native";
import { useFonts } from "expo-font";
import { LinearGradient } from "expo-linear-gradient";
@@ -14,9 +14,10 @@ import { useTrainMenu } from "./stateBox/useTrainMenu";
import lineColorList from "./assets/originData/lineColorList";
import { stationIDPair } from "./lib/getStationList";
import "./components/ActionSheetComponents/sheets";
import { rootNavigationRef } from "./lib/rootNavigation";
import { positionsLifecycleRef, rootNavigationRef } from "./lib/rootNavigation";
import { fixedColors } from "./lib/theme/colors";
import { useThemeColors } from "./lib/theme";
import * as Sentry from "@sentry/react-native";
type RootTabParamList = {
positions: undefined;
@@ -35,12 +36,46 @@ type TabProps = {
const Tab = createBottomTabNavigator<RootTabParamList>();
const DeferredPositionsRoot = ({ shouldActivate, isRootFocused }: { shouldActivate: boolean; isRootFocused: boolean }) => {
const [hasActivated, setHasActivated] = React.useState(false);
React.useEffect(() => {
Sentry.setContext("positions_root_gate", {
focused: isRootFocused,
activated: hasActivated,
shouldActivate,
});
}, [hasActivated, isRootFocused, shouldActivate]);
React.useEffect(() => {
if (!shouldActivate || hasActivated) return;
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root activation",
data: {
reason: "first_root_visit",
},
});
setHasActivated(true);
}, [hasActivated, shouldActivate]);
if (!hasActivated) {
return <View style={{ flex: 1 }} />;
}
return <Top />;
};
export function AppContainer() {
const { areaInfo, areaIconBadgeText, isInfo } = useAreaInfo();
const { selectedLine } = useTrainMenu();
const { width, height } = useWindowDimensions();
const operationScreenKey = width > height ? "operation-landscape" : "operation-portrait";
const [isExtraWindowOpen, setIsExtraWindowOpen] = React.useState(false);
const [hasVisitedPositions, setHasVisitedPositions] = React.useState(false);
const [currentRootRoute, setCurrentRootRoute] = React.useState<keyof RootTabParamList | null>(null);
const [navigationReady, setNavigationReady] = React.useState(false);
const startupPrewarmAttemptedRef = React.useRef(false);
// フェードアニメーション用 (0=通常, 1=追加ウィンドウ青)
const fadeAnim = React.useRef(new Animated.Value(0)).current;
@@ -64,6 +99,7 @@ export function AppContainer() {
return `#${[r, g, b].map((v) => Math.min(255, v).toString(16).padStart(2, "0")).join("")}`;
};
const { isDark } = useThemeColors();
const lastRootNavStateRef = React.useRef("");
const lineColorDark = lineColor ? darkenHex(lineColor, 0.78) : null;
const linking = {
prefixes: ["jrshikoku://"],
@@ -101,6 +137,7 @@ export function AppContainer() {
tabBarLabel: label,
headerShown: false,
gestureEnabled: true,
unmountOnBlur: false,
tabBarIcon: initIcon(icon as any, iconFamily, tabBarBadge, isInfo),
},
@@ -111,6 +148,49 @@ export function AppContainer() {
"JNR-font": require("./assets/fonts/JNRfont_pict.ttf"),
"DiaPro": require("./assets/fonts/DiaPro-Regular.otf"),
});
React.useEffect(() => {
if (!navigationReady || !fontLoaded) return;
if (startupPrewarmAttemptedRef.current) return;
if (hasVisitedPositions) {
startupPrewarmAttemptedRef.current = true;
return;
}
if (currentRootRoute === null || currentRootRoute === "positions") return;
startupPrewarmAttemptedRef.current = true;
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions startup prewarm scheduled",
data: {
currentRootRoute,
},
});
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const task = InteractionManager.runAfterInteractions(() => {
timer = setTimeout(() => {
if (cancelled || hasVisitedPositions) return;
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions startup prewarm activated",
data: {
currentRootRoute,
},
});
setHasVisitedPositions(true);
}, 1500);
});
return () => {
cancelled = true;
task.cancel?.();
if (timer) clearTimeout(timer);
};
}, [currentRootRoute, fontLoaded, hasVisitedPositions, navigationReady]);
if (!fontLoaded) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
@@ -123,14 +203,134 @@ export function AppContainer() {
ref={rootNavigationRef}
linking={linking}
theme={isDark ? DarkTheme : DefaultTheme}
onReady={() => {
Sentry.addBreadcrumb({
category: "nav.root",
level: "info",
message: "navigation ready",
});
setNavigationReady(true);
}}
onStateChange={(state) => {
const activeRoute = state?.routes?.[state?.index ?? 0];
const hasExtra = (activeRoute?.state?.index ?? 0) > 0;
const nestedState = activeRoute?.state;
const nestedRoute = nestedState?.routes?.[nestedState.index ?? 0]?.name ?? null;
const signature = JSON.stringify({
rootIndex: state?.index ?? 0,
rootRoute: activeRoute?.name ?? null,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
});
if (lastRootNavStateRef.current !== signature) {
lastRootNavStateRef.current = signature;
Sentry.addBreadcrumb({
category: "nav.root",
level: "info",
message: "root state change",
data: {
rootIndex: state?.index ?? 0,
rootRoute: activeRoute?.name ?? null,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
},
});
}
const rootRouteName = (activeRoute?.name ?? null) as keyof RootTabParamList | null;
Sentry.setTag("root_tab", rootRouteName ?? "unknown");
Sentry.setContext("root_navigation", {
rootIndex: state?.index ?? 0,
rootRoute: rootRouteName,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
operationOrientation: width > height ? "landscape" : "portrait",
hasVisitedPositions,
});
setCurrentRootRoute(rootRouteName);
setIsExtraWindowOpen(hasExtra);
}}
>
<Tab.Navigator
id="rootTabs"
detachInactiveScreens={false}
initialRouteName="topMenu"
screenListeners={({ route }) => ({
tabPress: (event) => {
const rootState = rootNavigationRef.getState();
const activeRootRouteName =
(rootState?.routes?.[rootState.index ?? 0]?.name as keyof RootTabParamList | undefined) ??
currentRootRoute ??
null;
const leavingUnstablePositions =
route.name !== "positions" &&
positionsLifecycleRef.current.isUnstable;
const blockingUnstableExit =
route.name !== "positions" &&
positionsLifecycleRef.current.blockTabExit;
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: blockingUnstableExit
? "root tabPress blocked while positions unstable"
: leavingUnstablePositions
? "root tabPress intercepted for unstable positions"
: "root tabPress",
data: {
route: route.name,
currentRootRoute,
activeRootRouteName,
unstablePositions: positionsLifecycleRef.current.isUnstable,
blockTabExit: positionsLifecycleRef.current.blockTabExit,
},
});
if (blockingUnstableExit) {
event.preventDefault();
return;
}
if (leavingUnstablePositions) {
event.preventDefault();
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root exit blocked while session unstable",
data: { route: route.name },
});
}
},
focus: () => {
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: "root tab focus",
data: { route: route.name },
});
},
blur: () => {
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: "root tab blur",
data: { route: route.name },
});
},
state: (event) => {
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: "root tab state",
data: {
route: route.name,
stateType: event.type,
},
});
},
})}
screenOptions={({ route }) => {
const showGradient = route.name === "positions" && !!lineColor && !!lineColorDark;
const defaultBg = isDark ? "#1c1c1e" : "white";
@@ -138,6 +338,7 @@ export function AppContainer() {
const defaultInactive = isDark ? "#8e8e93" : "#8e8e93";
return {
lazy: false,
freezeOnBlur: false,
sceneContainerStyle: { backgroundColor: defaultBg },
tabBarActiveTintColor: (showGradient || isExtraWindowOpen) ? "white" : defaultActive,
tabBarInactiveTintColor: (showGradient || isExtraWindowOpen) ? "rgba(255,255,255,0.75)" : defaultInactive,
@@ -171,8 +372,39 @@ export function AppContainer() {
>
<Tab.Screen
{...getTabProps("positions", "走行位置", "bar-chart", "AntDesign")}
component={Top}
/>
listeners={{
tabPress: () => {
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root tabPress activation",
});
setHasVisitedPositions(true);
},
focus: () => {
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root focus activation",
});
setHasVisitedPositions(true);
},
blur: () => {
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root blur preserved",
});
},
}}
>
{() => (
<DeferredPositionsRoot
shouldActivate={hasVisitedPositions}
isRootFocused={currentRootRoute === "positions"}
/>
)}
</Tab.Screen>
<Tab.Screen
{...getTabProps("topMenu", "トップメニュー", "radio", "Ionicons")}
component={MenuPage}
@@ -188,7 +420,7 @@ export function AppContainer() {
isInfo
)}
>
{() => <TNDView key={operationScreenKey} />}
{() => <TNDView />}
</Tab.Screen>
</Tab.Navigator>
</NavigationContainer>
+190 -14
View File
@@ -9,19 +9,120 @@ 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 } = route.params;
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 [selectedEnvironment, setSelectedEnvironment] = React.useState(
@@ -62,12 +163,12 @@ export default ({ route }) => {
const applyEnvironment = (value: unknown) => {
if (!isMounted) return;
const nextEnvironment = normalizeJrDataSystemEnvironment(value);
const rawUri = typeof uri === "string" ? uri : "";
setSelectedEnvironment(nextEnvironment);
setResolvedUri(
rewriteJrDataSystemUrl(
typeof uri === "string" ? uri : "",
nextEnvironment,
),
importRecordingDownloads
? rawUri
: rewriteJrDataSystemUrl(rawUri, nextEnvironment),
);
setIsEnvironmentReady(true);
};
@@ -79,7 +180,7 @@ export default ({ route }) => {
return () => {
isMounted = false;
};
}, [uri]);
}, [uri, importRecordingDownloads]);
const handleReload = () => {
lastPongAt.current = Date.now();
@@ -90,6 +191,44 @@ export default ({ route }) => {
setWebViewKey((k) => k + 1);
};
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],
);
// AppState監視: バックグラウンド10秒超で復帰したらWebViewを再マウント
React.useEffect(() => {
const onAppStateChange = (nextState: AppStateStatus) => {
@@ -130,8 +269,11 @@ export default ({ route }) => {
source={{ uri: resolvedUri }}
contentMode="mobile"
allowsBackForwardNavigationGestures
setSupportMultipleWindows={false}
ref={webViewRef}
injectedJavaScriptBeforeContentLoaded={`true;`}
injectedJavaScriptBeforeContentLoaded={
importRecordingDownloads ? RECORDING_DOWNLOAD_BRIDGE_SCRIPT : `true;`
}
onLoadStart={() => {
isLoadingRef.current = true;
setHasError(false);
@@ -173,16 +315,34 @@ export default ({ route }) => {
return true;
}
const rewrittenUrl = rewriteJrDataSystemUrl(
request.url,
selectedEnvironment,
);
if (rewrittenUrl !== request.url) {
setResolvedUri(rewrittenUrl);
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) => {
setCanGoBack(navState.canGoBack);
// SPA内遷移中は白画面誤検知を防ぐためblankCountをリセット
@@ -199,8 +359,24 @@ export default ({ route }) => {
}}
onMessage={(event) => {
const { data } = event.nativeEvent;
const parsed = JSON.parse(data);
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;
+79 -3
View File
@@ -18,11 +18,12 @@ import Setting from "@/components/Settings/settings";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { optionData } from "@/lib/stackOption";
import { AllTrainDiagramView } from "@/components/AllTrainDiagramView";
import { useNavigation } from "@react-navigation/native";
import { useNavigation, useIsFocused } from "@react-navigation/native";
import { news } from "@/config/newsUpdate";
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import GeneralWebView from "@/GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import * as Sentry from "@sentry/react-native";
const Stack = createStackNavigator();
export function MenuPage() {
@@ -32,8 +33,37 @@ export function MenuPage() {
const tabBarHeight = useBottomTabBarHeight();
const navigation = useNavigation<any>();
const { addListener } = navigation;
const isFocused = useIsFocused();
const isDark = useColorScheme() === "dark";
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
useEffect(() => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu mounted",
});
return () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu unmounted",
});
};
}, []);
useEffect(() => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: isFocused ? "top menu focused" : "top menu blurred",
data: {
width,
height,
},
});
}, [height, isFocused, width]);
useEffect(() => {
AS.getItem(STORAGE_KEYS.START_PAGE)
.then((res) => {
@@ -59,6 +89,14 @@ export function MenuPage() {
const scrollRef = useRef(null);
const [mapMode, setMapMode] = useState(false);
useEffect(() => {
Sentry.setContext("top_menu_screen", {
focused: isFocused,
width,
height,
mapMode,
});
}, [height, isFocused, mapMode, width]);
const [mapHeight, setMapHeight] = useState(0);
const mapHeightRef = useRef(0);
const favoriteStationRef = useRef(favoriteStation);
@@ -85,6 +123,16 @@ export function MenuPage() {
}, [height, tabBarHeight, width]);
useEffect(() => {
const unsubscribe = addListener("tabPress", (e: any) => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu tabPress handler",
data: {
focused: navigation.isFocused(),
stackIndex: stackNavRef.current?.getState()?.index ?? null,
mapMode,
},
});
if (navigation.isFocused() && stackNavRef.current) {
if (stackNavRef.current.getState()?.index > 0) {
e.preventDefault();
@@ -119,9 +167,37 @@ export function MenuPage() {
<Stack.Navigator
id={null}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ navigation: stackNav }) => {
screenListeners={({ route, navigation: stackNav }) => {
stackNavRef.current = stackNav;
return {};
return {
focus: () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu stack focus",
data: { route: route.name },
});
},
blur: () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu stack blur",
data: { route: route.name },
});
},
state: () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu stack state",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
};
}}
>
<Stack.Screen
+146 -10
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { createStackNavigator } from "@react-navigation/stack";
import { useNavigation } from "@react-navigation/native";
import { useIsFocused, useNavigation } from "@react-navigation/native";
import { useColorScheme } from "react-native";
import Apps from "./components/Apps";
import TrainBase from "./components/trainbaseview";
@@ -13,16 +13,20 @@ import { useCurrentTrain } from "./stateBox/useCurrentTrain";
import { useTrainMenu } from "./stateBox/useTrainMenu";
import { AS } from "./storageControl";
import { news } from "./config/newsUpdate";
import { Linking, Platform } from "react-native";
import { InteractionManager, Linking, Platform, View } from "react-native";
import GeneralWebView from "./GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import { positionsStackNavRef } from "./lib/rootNavigation";
import { positionsLifecycleRef, positionsStackNavRef } from "./lib/rootNavigation";
import * as Sentry from "@sentry/react-native";
const Stack = createStackNavigator();
export const Top = () => {
const { webview } = useCurrentTrain();
const { navigate, addListener, isFocused } = useNavigation<any>();
const navigation = useNavigation<any>();
const { navigate, addListener } = navigation;
const isTabFocused = useIsFocused();
const isDark = useColorScheme() === "dark";
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
const [hasActivatedStack, setHasActivatedStack] = useState(false);
//地図用
const { mapSwitch } = useTrainMenu();
@@ -39,16 +43,94 @@ export const Top = () => {
return unsubscribe;
}, []);
useEffect(() => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack mounted",
});
return () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack unmounted",
});
};
}, []);
useEffect(() => {
if (hasActivatedStack) return;
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack activation scheduled",
data: {
reason: isTabFocused ? "first_focus" : "background_prewarm",
},
});
let cancelled = false;
const task = InteractionManager.runAfterInteractions(() => {
setTimeout(() => {
if (cancelled) return;
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack activation committed",
data: {
reason: isTabFocused ? "first_focus" : "background_prewarm",
},
});
setHasActivatedStack(true);
}, isTabFocused ? 0 : 300);
});
return () => {
cancelled = true;
task.cancel?.();
};
}, [hasActivatedStack, isTabFocused]);
useEffect(() => {
Sentry.setContext("positions_stack", {
focused: isTabFocused,
activated: hasActivatedStack,
mapSwitch,
});
}, [hasActivatedStack, isTabFocused, mapSwitch]);
const stackNavRef = positionsStackNavRef;
useEffect(() => {
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
deactivateStack: null,
};
return () => {
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
deactivateStack: null,
};
};
}, []);
const goToTrainMenu = useCallback((e: any) => {
const stackNav = stackNavRef.current;
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions tabPress handler",
data: {
platform: Platform.OS,
focused: isTabFocused,
mapSwitch: mapSwitchRef.current,
stackIndex: stackNav?.getState()?.index ?? null,
},
});
if (Platform.OS === "web") {
Linking.openURL("https://train.jr-shikoku.co.jp/");
setTimeout(() => navigate("topMenu", { screen: "menu" }), 100);
return;
}
if (!isFocused()) return;
const stackNav = stackNavRef.current;
if (!isTabFocused) return;
if (stackNav && stackNav.getState()?.index > 0) {
e.preventDefault();
stackNav.goBack();
@@ -58,20 +140,74 @@ export const Top = () => {
navigate("positions", { screen: "trainMenu" });
else webview.current?.injectJavaScript(`AccordionClassEvent()`);
return;
}, [isFocused, navigate, webview]);
}, [isTabFocused, navigate, webview]);
useEffect(() => {
const unsubscribe = addListener("tabPress", goToTrainMenu);
return unsubscribe;
}, [addListener, goToTrainMenu]);
if (!hasActivatedStack) {
return <View style={{ flex: 1, backgroundColor: bgColor }} />;
}
return (
<Stack.Navigator
id={null}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ navigation: stackNav }) => {
screenListeners={({ route, navigation: stackNav }) => {
stackNavRef.current = stackNav;
return {};
return {
focus: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack focus",
data: { route: route.name },
});
},
blur: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack blur",
data: { route: route.name },
});
},
transitionStart: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack transitionStart",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
transitionEnd: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack transitionEnd",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
state: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack state",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
};
}}
>
<Stack.Screen
+22 -4
View File
@@ -8,7 +8,7 @@
"android",
"web"
],
"version": "7.0.0",
"version": "7.1.0",
"userInterfaceStyle": "automatic",
"orientation": "default",
"icon": "./assets/icons/s8600.png",
@@ -24,7 +24,7 @@
"**/*"
],
"ios": {
"buildNumber": "63",
"buildNumber": "65",
"supportsTablet": true,
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
"appleTeamId": "54CRDT797G",
@@ -33,6 +33,8 @@
},
"infoPlist": {
"NFCReaderUsageDescription": "To read FeliCa card",
"NSPhotoLibraryUsageDescription": "運行情報画像を共有または保存するために写真ライブラリへのアクセスを使用します。",
"NSPhotoLibraryAddUsageDescription": "運行情報画像を写真ライブラリに保存するために使用します。",
"com.apple.developer.nfc.readersession.felica.systemcodes": [
"0003",
"FE00"
@@ -55,7 +57,7 @@
},
"android": {
"package": "jrshikokuinfo.xprocess.hrkn",
"versionCode": 30,
"versionCode": 31,
"intentFilters": [
{
"action": "VIEW",
@@ -970,13 +972,29 @@
"expo-web-browser",
"expo-asset",
"expo-sharing",
[
"react-native-share",
{
"ios": [],
"android": [],
"enableBase64ShareAndroid": false
}
],
[
"react-native-maps",
{
"iosGoogleMapsApiKey": "AIzaSyAVGDTjBkR_0wkQiNkoo5WDLhqXCjrjk8Y",
"androidGoogleMapsApiKey": "AIzaSyAmFb-Yj033bXZWlSzNrfq_0jc1PgRrWcE"
}
],
[
"@sentry/react-native/expo",
{
"url": "https://sentry.io/",
"project": "jr-shikoku-unofficial-apps",
"organization": "xprocess-m5"
}
]
]
}
}
}
+265 -36
View File
@@ -1,10 +1,12 @@
import React from "react";
import { InteractionManager } from "react-native";
import {
View,
Platform,
useWindowDimensions,
StatusBar,
useColorScheme,
Button,
} from "react-native";
import * as Updates from "expo-updates";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -23,9 +25,11 @@ import { NewMenu } from "./Apps/NewMenu";
import { MapsButton } from "./Apps/MapsButton";
import { ReloadButton } from "./Apps/ReloadButton";
import { useStationList } from "../stateBox/useStationList";
import { positionsLifecycleRef } from "../lib/rootNavigation";
import { FixedPositionBox } from "./Apps/FixedPositionBox";
import { PlaybackTimeline } from "./Apps/PlaybackTimeline";
import { RecordingStatusBar } from "./Apps/RecordingStatusBar";
import * as Sentry from "@sentry/react-native";
export default function Apps() {
const { webview, fixedPosition, setFixedPosition } = useCurrentTrain();
@@ -33,11 +37,15 @@ export default function Apps() {
const { navigate } = useNavigation<any>();
const { isLandscape } = useDeviceOrientationChange();
const { top } = useSafeAreaInsets();
const handleLayout = () => {};
const { originalStationList } = useStationList();
const { mapSwitch, trainInfo, setTrainInfo, selectedLine } = useTrainMenu();
const isDark = useColorScheme() === "dark";
const isFocused = useIsFocused();
const [hasActivatedScreen, setHasActivatedScreen] = React.useState(false);
const [hasActivatedWebView, setHasActivatedWebView] = React.useState(false);
const [hasStableWebViewSession, setHasStableWebViewSession] = React.useState(false);
const lastFocusSignatureRef = React.useRef("");
const lastLayoutSignatureRef = React.useRef("");
const lineColor = selectedLine && stationIDPair[selectedLine]
? lineColorList[stationIDPair[selectedLine]]
@@ -84,20 +92,209 @@ export default function Apps() {
}
};
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
const resetTransientPositionsSession = React.useCallback(() => {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: hasStableWebViewSession
? "positions stable session preserved before leave"
: "positions transient session reset before leave",
data: {
stableWebViewSession: hasStableWebViewSession,
},
});
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
isUnstable: false,
blockTabExit: false,
};
if (hasStableWebViewSession) {
return;
}
try {
webview.current?.stopLoading?.();
} catch {}
setHasActivatedWebView(false);
setHasActivatedScreen(false);
setHasStableWebViewSession(false);
}, [hasStableWebViewSession, webview]);
React.useEffect(() => {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen mounted",
});
return () => {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen unmounted",
});
};
}, []);
React.useEffect(() => {
if (!isFocused) {
if (!hasActivatedWebView && !hasStableWebViewSession) {
// allow one hidden startup prewarm so the positions session can begin before first tab focus
} else {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions webview preserved on blur",
data: {
stableWebViewSession: hasStableWebViewSession,
activatedWebView: hasActivatedWebView,
},
});
return;
}
}
if (hasActivatedScreen && hasActivatedWebView) return;
const activationReason = isFocused
? (hasActivatedScreen ? "focus" : "first_focus")
: "background_prewarm";
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen activation scheduled",
data: {
reason: activationReason,
},
});
let cancelled = false;
const task = InteractionManager.runAfterInteractions(() => {
setTimeout(() => {
if (cancelled) {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen activation cancelled before webview mount",
data: {
reason: activationReason,
},
});
return;
}
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen activation committed",
data: {
reason: activationReason,
},
});
setHasActivatedScreen(true);
setHasActivatedWebView(true);
}, isFocused ? 250 : 700);
});
return () => {
cancelled = true;
task.cancel?.();
};
}, [hasActivatedScreen, hasActivatedWebView, hasStableWebViewSession, isFocused]);
React.useEffect(() => {
const shouldGuardTabExit = isFocused && hasActivatedWebView && !hasStableWebViewSession;
positionsLifecycleRef.current = {
isUnstable: shouldGuardTabExit,
blockTabExit: shouldGuardTabExit,
resetBeforeLeave: resetTransientPositionsSession,
deactivateStack: positionsLifecycleRef.current.deactivateStack,
};
return () => {
positionsLifecycleRef.current = {
isUnstable: false,
blockTabExit: false,
resetBeforeLeave: null,
deactivateStack: positionsLifecycleRef.current.deactivateStack,
};
};
}, [hasActivatedWebView, hasStableWebViewSession, isFocused, resetTransientPositionsSession]);
React.useEffect(() => {
Sentry.setContext("positions_screen", {
focused: isFocused,
activatedScreen: hasActivatedScreen,
activatedWebView: hasActivatedWebView,
stableWebViewSession: hasStableWebViewSession,
mapSwitch,
selectedLine: selectedLine ?? null,
trainNum: trainInfo?.trainNum ?? null,
landscape: isLandscape,
width,
height,
});
}, [hasActivatedScreen, hasActivatedWebView, hasStableWebViewSession, height, isFocused, isLandscape, mapSwitch, selectedLine, trainInfo?.trainNum, width]);
React.useEffect(() => {
const signature = JSON.stringify({
focused: isFocused,
mapSwitch,
selectedLine: selectedLine ?? null,
trainNum: trainInfo?.trainNum ?? null,
landscape: isLandscape,
});
if (lastFocusSignatureRef.current === signature) return;
lastFocusSignatureRef.current = signature;
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: isFocused ? "positions focused" : "positions blurred",
data: {
mapSwitch,
selectedLine: selectedLine ?? null,
trainNum: trainInfo?.trainNum ?? null,
landscape: isLandscape,
},
});
}, [hasActivatedWebView, isFocused, isLandscape, mapSwitch, selectedLine, trainInfo?.trainNum]);
const handleLayout = React.useCallback((event) => {
const layout = event?.nativeEvent?.layout;
if (!layout) return;
const signature = [Math.round(layout.width), Math.round(layout.height), isLandscape ? "landscape" : "portrait"].join(":");
if (lastLayoutSignatureRef.current === signature) return;
lastLayoutSignatureRef.current = signature;
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions layout",
data: {
width: Math.round(layout.width),
height: Math.round(layout.height),
landscape: isLandscape,
},
});
}, [isLandscape]);
return (
<View style={{ flex: 1, backgroundColor: bgColor }}>
{isFocused && mapSwitch === "true" && lineColor && lineColorDark && (
<LinearGradient
colors={[lineColorDark, lineColor]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={{ position: "absolute", top: 0, left: 0, right: 0, height: top }}
/>
)}
{isFocused && mapSwitch !== "true" && (
<View style={{ position: "absolute", top: 0, left: 0, right: 0, height: top, backgroundColor: "#0099CC" }} />
)}
{isFocused && (
<View
pointerEvents="none"
style={{ position: "absolute", top: 0, left: 0, right: 0, height: top }}
>
{mapSwitch === "true" && lineColor && lineColorDark ? (
<LinearGradient
colors={[lineColorDark, lineColor]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={{ flex: 1 }}
/>
) : (
<View
style={{
flex: 1,
backgroundColor: "#0099CC",
}}
/>
)}
</View>
)}
{isFocused && hasActivatedScreen && (
<StatusBar barStyle="light-content" />
)}
<View
@@ -108,32 +305,64 @@ export default function Apps() {
}}
onLayout={handleLayout}
>
<AppsWebView
{...{
openStationACFromEachTrainInfo,
}}
/>
{hasActivatedScreen ? (
<>
{hasActivatedWebView ? (
<AppsWebView
openStationACFromEachTrainInfo={openStationACFromEachTrainInfo}
onInitialLoadReady={() => {
if (hasStableWebViewSession) return;
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions webview became stable",
});
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
isUnstable: false,
blockTabExit: false,
};
setHasStableWebViewSession(true);
}}
/>
) : (
<View style={{ flex: 1 }} />
)}
<MapsButton
onPress={() => {
navigate("trainMenu", { webview });
}}
/>
{isFocused && (
<MapsButton
onPress={() => {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "maps button press",
data: {
mapSwitch,
selectedLine: selectedLine ?? null,
hasActivatedWebView,
},
});
navigate("trainMenu", { webview });
}}
/>
)}
{fixedPosition.type && <FixedPositionBox />}
<PlaybackTimeline />
<RecordingStatusBar />
{mapSwitch == "true" ? (
<>
<ReloadButton
onPress={() => Updates.reloadAsync()}
right={isLandscape && trainInfo.trainNum ? (width / 100) * 40 : 0}
/>
</>
) : (
<NewMenu />
)}
{isFocused && fixedPosition.type && <FixedPositionBox />}
{isFocused && <PlaybackTimeline />}
{isFocused && <RecordingStatusBar />}
{isFocused &&
(mapSwitch == "true" ? (
<ReloadButton
onPress={() => Updates.reloadAsync()}
right={isLandscape && trainInfo.trainNum ? (width / 100) * 40 : 0}
/>
) : (
<NewMenu />
))}
</>
) : (
<View style={{ flex: 1 }} />
)}
</View>
</View>
);
+155 -13
View File
@@ -15,15 +15,17 @@ import { useCurrentTrain } from "../../stateBox/useCurrentTrain";
import { useDeviceOrientationChange } from "../../stateBox/useDeviceOrientationChange";
import { SheetManager } from "react-native-actions-sheet";
import { useNavigation } from "@react-navigation/native";
import { useNavigation, useIsFocused } from "@react-navigation/native";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useStationList } from "../../stateBox/useStationList";
import { useThemeColors } from "@/lib/theme";
import { useWebViewRemount } from "@/lib/useWebViewRemount";
import { generateMockUpdateScript } from "../../lib/mockApi/webviewXhrInterceptor";
export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
import * as Sentry from "@sentry/react-native";
export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady }) => {
const { webview, currentTrain } = useCurrentTrain();
const { navigate } = useNavigation<any>();
const isFocused = useIsFocused();
const { favoriteStation } = useFavoriteStation();
const { isLandscape } = useDeviceOrientationChange();
const { isDark } = useThemeColors();
@@ -40,27 +42,98 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
mockApiFeatureEnabled,
mockTrainPositions,
} = useTrainMenu();
const { remountKey, remount, processHandlers } = useWebViewRemount();
const addWebViewBreadcrumb = (
message: string,
data?: Record<string, string | number | boolean | null | undefined>
) => {
Sentry.addBreadcrumb({
category: "positions.webview",
level: "info",
message,
data,
});
};
const { remountKey, processHandlers } = useWebViewRemount({
isFocused,
pauseWatchdogWhenUnfocused: Platform.OS === "ios",
ignoreProcessTerminationWhenUnfocused: Platform.OS === "ios",
onRemount: (reason, data) => {
addWebViewBreadcrumb("webview remount requested", {
reason,
...(data ?? {}),
});
},
});
const lastRemountKeyRef = useRef<typeof remountKey | null>(null);
// コマが変化したとき(再生・シーク)に WebView 内の _MOCK_TRAIN を差し替えて再描画
const mountedRef = useRef(false);
const urlCacheRef = useRef("");
const initialInjectDoneRef = useRef(false);
const loadEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const focusedRef = useRef(isFocused);
const initialLoadReadyNotifiedRef = useRef(false);
useEffect(() => {
focusedRef.current = isFocused;
addWebViewBreadcrumb(isFocused ? "webview focused" : "webview blurred", {
landscape: isLandscape,
mockApi: mockApiFeatureEnabled,
});
}, [isFocused, isLandscape, mockApiFeatureEnabled]);
useEffect(() => {
if (lastRemountKeyRef.current !== remountKey) {
addWebViewBreadcrumb("webview remount key", { remountKey });
lastRemountKeyRef.current = remountKey;
}
}, [remountKey]);
useEffect(() => {
Sentry.setContext("positions_webview", {
focused: isFocused,
landscape: isLandscape,
mockApi: mockApiFeatureEnabled,
remountKey,
currentUrl: urlCacheRef.current || null,
});
}, [isFocused, isLandscape, mockApiFeatureEnabled, remountKey]);
useEffect(() => {
return () => {
addWebViewBreadcrumb("webview cleanup start", {
focused: focusedRef.current,
currentUrl: urlCacheRef.current || null,
});
if (loadEndTimeoutRef.current) {
clearTimeout(loadEndTimeoutRef.current);
loadEndTimeoutRef.current = null;
}
addWebViewBreadcrumb("webview unmounted");
};
}, []);
useEffect(() => {
// 初回マウント時はスキップ(beforeContentLoaded で既に正しいデータが入っている)
if (!mountedRef.current) {
mountedRef.current = true;
addWebViewBreadcrumb("mock positions effect skipped initial mount", {
mockApi: mockApiFeatureEnabled,
});
return;
}
if (!mockApiFeatureEnabled || !mockTrainPositions) return;
addWebViewBreadcrumb("mock positions injected", {
positionCount: mockTrainPositions.length,
});
const script = generateMockUpdateScript(mockTrainPositions);
webview?.current?.injectJavaScript(script);
}, [mockTrainPositions]);
var urlcache = "";
let once = false;
}, [mockApiFeatureEnabled, mockTrainPositions, webview]);
const onNavigationStateChange = ({ url }) => {
if (url == urlcache) return;
if (url == urlCacheRef.current) return;
//URL二重判定回避
urlcache = url;
urlCacheRef.current = url;
addWebViewBreadcrumb("webview navigation", { url });
switch (true) {
case url.includes("https://train.jr-shikoku.co.jp/usage.htm"):
if (Platform.OS === "android") navigate("howto", { info: url });
@@ -82,14 +155,27 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
* {type,event,id,name,pdf,map,url,chk}
*/
if (data.includes("train.html")) {
addWebViewBreadcrumb("webview train link", { data });
navigate("trainbase", { info: data, from: "Train" });
return;
}
if (!originalStationList) {
addWebViewBreadcrumb("webview message blocked waiting station data");
alert("駅名標データを取得中...");
return;
}
const dataSet = JSON.parse(data);
let dataSet;
try {
dataSet = JSON.parse(data);
} catch (error) {
addWebViewBreadcrumb("webview message parse failed", {
dataPreview: data.slice(0, 120),
});
throw error;
}
addWebViewBreadcrumb("webview message", {
type: dataSet.type ?? "unknown",
});
switch (dataSet.type) {
case "LoadError": {
setLoadError(true);
@@ -153,8 +239,28 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
}
};
const onLoadStart = () => {
addWebViewBreadcrumb("webview loadStart", {
focused: focusedRef.current,
currentUrl: urlCacheRef.current || null,
});
};
const onLoadEnd = () => {
if (once) return () => {};
addWebViewBreadcrumb("webview loadEnd", {
focused: focusedRef.current,
hasStationData: !!stationData,
hasOriginalStationList: !!originalStationList,
favoriteCount: favoriteStation.length,
});
if (!initialLoadReadyNotifiedRef.current) {
initialLoadReadyNotifiedRef.current = true;
addWebViewBreadcrumb("webview initial load ready", {
focused: focusedRef.current,
});
onInitialLoadReady?.();
}
if (initialInjectDoneRef.current) return () => {};
if (!stationData) return () => {};
if (!originalStationList) return () => {};
if (favoriteStation.length < 1) return () => {};
@@ -162,10 +268,33 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
favoriteStation[0][0].StationNumber
);
if (!string) return () => {};
setTimeout(() => {
if (loadEndTimeoutRef.current) {
clearTimeout(loadEndTimeoutRef.current);
}
addWebViewBreadcrumb("webview initial inject scheduled");
loadEndTimeoutRef.current = setTimeout(() => {
if (!focusedRef.current) {
addWebViewBreadcrumb("webview initial inject skipped while blurred");
loadEndTimeoutRef.current = null;
return;
}
addWebViewBreadcrumb("webview initial inject run");
webview?.current?.injectJavaScript(string);
loadEndTimeoutRef.current = null;
}, 500);
once = true;
initialInjectDoneRef.current = true;
};
const handleRenderProcessGone = (event) => {
addWebViewBreadcrumb("webview render process gone", {
didCrash: event.nativeEvent.didCrash,
});
processHandlers.onRenderProcessGone?.(event);
};
const handleContentProcessDidTerminate = () => {
addWebViewBreadcrumb("webview content process terminated");
processHandlers.onContentProcessDidTerminate?.();
};
return (
@@ -183,8 +312,21 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
setSupportMultipleWindows
contentMode="mobile"
style={{ backgroundColor: bgColor }}
onLoadStart={onLoadStart}
onError={(event) => {
addWebViewBreadcrumb("webview error", {
description: event.nativeEvent.description || null,
code: event.nativeEvent.code ?? null,
});
}}
onHttpError={(event) => {
addWebViewBreadcrumb("webview http error", {
statusCode: event.nativeEvent.statusCode ?? null,
});
}}
onRenderProcessGone={handleRenderProcessGone}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
{...{ onMessage, onNavigationStateChange, onLoadEnd }}
{...processHandlers}
injectedJavaScriptBeforeContentLoaded={injectJavascriptBeforeContentLoaded}
injectedJavaScript={injectJavascript}
/>
+137 -40
View File
@@ -9,6 +9,7 @@ import {
TouchableOpacity,
Text,
ScrollView,
Platform,
} from "react-native";
import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel";
import { SheetManager } from "react-native-actions-sheet";
@@ -27,6 +28,7 @@ import Animated, {
} from "react-native-reanimated";
import { useSortMode } from "./useSortMode";
import { StationSource } from "@/types";
import * as Sentry from "@sentry/react-native";
export const CarouselBox = ({
originalStationList,
@@ -45,7 +47,11 @@ export const CarouselBox = ({
navigate: any;
stationSource: StationSource;
}) => {
const isAndroid = Platform.OS === "android";
const sortControlsEntering = isAndroid ? undefined : FadeIn.duration(200);
const sortControlsExiting = isAndroid ? undefined : FadeOut.duration(150);
const carouselRef = useRef<ICarouselInstance>(null);
const androidCarouselScrollRef = useRef<ScrollView>(null);
const { width } = useWindowDimensions();
const { colors, fixed } = useThemeColors();
const { fontScale } = useResponsive();
@@ -109,6 +115,7 @@ export const CarouselBox = ({
// コンテナ高さ(カルーセル ↔ グリッドで可変)
const containerHeight = useSharedValue(carouselHeight);
const containerHeightStyle = useAnimatedStyle(() => ({ height: containerHeight.value }));
const androidContainerStyle = isAndroid ? { height: isSortMode ? gridHeight : carouselHeight } : null;
// ドットエリアのフェード
const dotsOpacity = useSharedValue(1);
@@ -122,6 +129,11 @@ export const CarouselBox = ({
const gridAnimStyle = useAnimatedStyle(() => ({ opacity: gridOpacity.value }));
useEffect(() => {
if (isAndroid) {
setIsGridMounted(isSortMode);
return;
}
const duration = 250;
if (isSortMode) {
setIsGridMounted(true); // フェードイン前にマウント
@@ -137,7 +149,7 @@ export const CarouselBox = ({
if (finished) runOnJS(setIsGridMounted)(false); // フェードアウト完了後にアンマウント
});
}
}, [isSortMode, gridHeight, carouselHeight]);
}, [isAndroid, isSortMode, gridHeight, carouselHeight]);
// ソートモード終了直後フラグ(次の listIndex 変更でアニメーションをスキップ)
const justExitedSortRef = useRef(false);
@@ -150,12 +162,18 @@ export const CarouselBox = ({
// バッジからのインデックス変更をカルーセルに反映
useEffect(() => {
if (listIndex >= 0 && carouselRef.current) {
const animated = !justExitedSortRef.current;
justExitedSortRef.current = false;
carouselRef.current.scrollTo({ index: listIndex, animated });
if (listIndex < 0) return;
const animated = !justExitedSortRef.current;
justExitedSortRef.current = false;
if (Platform.OS === "android") {
androidCarouselScrollRef.current?.scrollTo({
x: width * listIndex,
animated,
});
return;
}
}, [listIndex]);
carouselRef.current?.scrollTo({ index: listIndex, animated });
}, [listIndex, width]);
// ドットのスクロール追従
useEffect(() => {
@@ -167,9 +185,35 @@ export const CarouselBox = ({
// ドット表示設定の読み込み
useEffect(() => {
AS.getItem("CarouselSettings/activeDotSettings").then((data) => {
setDotButton(data === "true");
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "info",
message: "carousel activeDotSettings read scheduled",
});
AS.getItem("CarouselSettings/activeDotSettings")
.then((data) => {
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "info",
message: "carousel activeDotSettings read success",
data: {
rawValue: data == null ? "<null>" : String(data),
resolved: data === "true",
},
});
setDotButton(data === "true");
})
.catch((error) => {
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "error",
message: "carousel activeDotSettings read failed",
data: {
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error),
},
});
});
}, []);
const oPSign = () => {
@@ -199,7 +243,38 @@ export const CarouselBox = ({
duration: 600,
update: { type: "spring", springDamping: 0.5 },
});
AS.setItem("CarouselSettings/activeDotSettings", !dotButton ? "true" : "false");
const nextValue = !dotButton ? "true" : "false";
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "info",
message: "carousel activeDotSettings write scheduled",
data: {
nextValue,
},
});
void AS.setItem("CarouselSettings/activeDotSettings", nextValue)
.then(() => {
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "info",
message: "carousel activeDotSettings write success",
data: {
nextValue,
},
});
})
.catch((error) => {
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "error",
message: "carousel activeDotSettings write failed",
data: {
nextValue,
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error),
},
});
});
setDotButton(!dotButton);
};
@@ -255,37 +330,59 @@ export const CarouselBox = ({
return (
<View style={{ flex: 1, paddingTop: 10 }}>
{/* カルーセル / グリッド(同じ高さ領域を共用・クロスフェード) */}
<Animated.View style={[{ overflow: "visible" }, containerHeightStyle]}>
<Animated.View style={[{ overflow: "visible" }, isAndroid ? androidContainerStyle : containerHeightStyle]}>
{/* カルーセル */}
<Animated.View
style={[{ position: "absolute", width }, carouselAnimStyle]}
style={[{ position: "absolute", width }, isAndroid ? { opacity: isSortMode ? 0 : 1 } : carouselAnimStyle]}
pointerEvents={isSortMode ? "none" : "auto"}
>
<Carousel
ref={carouselRef}
data={listUpStation.length > 0 ? listUpStation : [[{ StationNumber: "null" }]]}
height={carouselHeight}
pagingEnabled={true}
snapEnabled={true}
loop={false}
width={width}
style={{ width, alignContent: "center" }}
mode="parallax"
modeConfig={{
parallaxScrollingScale: 1,
parallaxScrollingOffset: 100,
parallaxAdjacentItemScale: 0.8,
}}
scrollAnimationDuration={600}
onSnapToItem={setListIndex}
renderItem={RenderItem}
overscrollEnabled={false}
defaultIndex={
lastValidListIndexRef.current >= listUpStation.length
? 0
: lastValidListIndexRef.current
}
/>
{Platform.OS === "android" ? (
<ScrollView
ref={androidCarouselScrollRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
overScrollMode="never"
onMomentumScrollEnd={({ nativeEvent }) => {
const nextIndex = Math.round(nativeEvent.contentOffset.x / width);
if (nextIndex !== listIndex) {
setListIndex(nextIndex);
}
}}
>
{(listUpStation.length > 0 ? listUpStation : [[{ StationNumber: "null" }]]).map((item, index) => (
<View key={item[0].StationNumber ?? item[0].Station_JP ?? index} style={{ width }}>
<RenderItem item={item} />
</View>
))}
</ScrollView>
) : (
<Carousel
ref={carouselRef}
data={listUpStation.length > 0 ? listUpStation : [[{ StationNumber: "null" }]]}
height={carouselHeight}
pagingEnabled={true}
snapEnabled={true}
loop={false}
width={width}
style={{ width, alignContent: "center" }}
mode="parallax"
modeConfig={{
parallaxScrollingScale: 1,
parallaxScrollingOffset: 100,
parallaxAdjacentItemScale: 0.8,
}}
scrollAnimationDuration={600}
onSnapToItem={setListIndex}
renderItem={RenderItem}
overscrollEnabled={false}
defaultIndex={
lastValidListIndexRef.current >= listUpStation.length
? 0
: lastValidListIndexRef.current
}
/>
)}
</Animated.View>
{/* グリッド:ソートモード中のみマウント */}
@@ -293,7 +390,7 @@ export const CarouselBox = ({
<Animated.View
style={[
{ position: "absolute", width, height: gridHeight, paddingHorizontal: gridPad, overflow: "visible" },
gridAnimStyle,
isAndroid ? { opacity: 1 } : gridAnimStyle,
]}
>
<Sortable.Grid
@@ -311,7 +408,7 @@ export const CarouselBox = ({
</Animated.View>
{/* ドットエリア:ソートモード時はフェードアウト */}
<Animated.View style={dotsAnimStyle} pointerEvents={isSortMode ? "none" : "auto"}>
<Animated.View style={isAndroid ? { opacity: isSortMode ? 0 : 1 } : dotsAnimStyle} pointerEvents={isSortMode ? "none" : "auto"}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
@@ -355,8 +452,8 @@ export const CarouselBox = ({
{/* 並び替えコントロール:ソートモード時に最下部からスライドイン */}
{isSortMode && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
entering={sortControlsEntering}
exiting={sortControlsExiting}
style={{
flexDirection: "row",
alignItems: "center",
+2 -258
View File
@@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect } from "react";
import {
Alert,
View,
Text,
ScrollView,
@@ -12,7 +11,6 @@ import {
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import Swipeable from "react-native-gesture-handler/Swipeable";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
@@ -278,19 +276,7 @@ const ELESITE_FEATURES: Feature[] = [
/* ------------------------------------------------------------------ */
export const DataSourceSettings = () => {
const navigation = useNavigation();
const {
updatePermission,
mockApiFeatureEnabled,
setMockApiFeatureEnabled,
recorderState,
recordingSnapshotCount,
recordingList,
startRecording,
stopRecording,
startPlayback,
stopPlayback,
deleteRecording,
} = useTrainMenu();
const { updatePermission } = useTrainMenu();
const { colors, fixed } = useThemeColors();
const showDebugSelector = __DEV__ || updatePermission;
const [useUnyohub, setUseUnyohub] = useState(false);
@@ -304,8 +290,6 @@ export const DataSourceSettings = () => {
useState<JrDataSystemUiVariant>(
getJrDataSystemUiVariant(DEFAULT_JR_DATA_SYSTEM_ENV),
);
const recordingSwipeRefs = useRef<Record<string, { close: () => void } | null>>({});
const applyJrDataSystemEnv = (env: JrDataSystemEnvironmentKey) => {
setJrDataSystemEnv(env);
setJrDataSystemTrack(getJrDataSystemTrack(env));
@@ -361,28 +345,6 @@ export const DataSourceSettings = () => {
applyJrDataSystemEnv(env);
};
const closeRecordingSwipe = (id: string) => {
recordingSwipeRefs.current[id]?.close();
};
const confirmDeleteRecording = (id: string, label: string) => {
closeRecordingSwipe(id);
Alert.alert(
"録画を削除",
`${label} の録画を削除しますか?`,
[
{ text: "キャンセル", style: "cancel" },
{
text: "削除",
style: "destructive",
onPress: () => {
void deleteRecording(id);
},
},
],
);
};
return (
<View style={[styles.container, { backgroundColor: fixed.primary }]}>
<SheetHeaderItem
@@ -451,224 +413,6 @@ export const DataSourceSettings = () => {
{showDebugSelector && (
<>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>
デバッグ: モックAPI検証
</Text>
<Text
style={[
styles.debugDescription,
{ color: colors.textSecondary },
]}
>
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 8,
}}
>
<Text
style={[
styles.debugCurrentText,
{ color: colors.textPrimary, fontSize: 14 },
]}
>
API検証機能
</Text>
<Switch
value={mockApiFeatureEnabled}
onValueChange={setMockApiFeatureEnabled}
color={fixed.primary}
/>
</View>
</View>
{/* 録画・再生 */}
<View
style={[
styles.debugSection,
{ backgroundColor: colors.surface, borderColor: colors.borderSecondary },
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>
デバッグ: 走行位置録画
</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>
OFFになります
</Text>
{/* ステータス行 */}
<View style={{ flexDirection: "row", alignItems: "center", marginTop: 8, gap: 8 }}>
<View
style={{
width: 10, height: 10, borderRadius: 5,
backgroundColor:
recorderState === 'recording' ? '#e53935' :
recorderState === 'playing' ? '#43a047' : colors.borderSecondary,
}}
/>
<Text style={[styles.debugCurrentText, { color: colors.textSecondary, fontSize: 13 }]}>
{recorderState === 'recording'
? `録画中… ${recordingSnapshotCount} スナップショット`
: recorderState === 'playing'
? '再生中'
: `${recordingList.length} 件の録画`}
</Text>
</View>
{/* 録画開始 / 停止ボタン */}
<View style={{ flexDirection: "row", gap: 8, marginTop: 10, flexWrap: "wrap" }}>
{recorderState === 'idle' && (
<TouchableOpacity
onPress={startRecording}
style={{
backgroundColor: '#e53935', borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 8,
}}
>
<Text style={{ color: '#fff', fontWeight: 'bold', fontSize: 13 }}> </Text>
</TouchableOpacity>
)}
{recorderState === 'recording' && (
<TouchableOpacity
onPress={stopRecording}
style={{
backgroundColor: colors.borderSecondary, borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 8,
}}
>
<Text style={{ color: colors.textPrimary, fontWeight: 'bold', fontSize: 13 }}> </Text>
</TouchableOpacity>
)}
{recorderState === 'playing' && (
<TouchableOpacity
onPress={stopPlayback}
style={{
backgroundColor: colors.borderSecondary, borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 8,
}}
>
<Text style={{ color: colors.textPrimary, fontWeight: 'bold', fontSize: 13 }}> </Text>
</TouchableOpacity>
)}
</View>
{/* 録画一覧 */}
{recordingList.length > 0 && recorderState !== 'recording' && (
<View style={{ marginTop: 10, gap: 6 }}>
{recordingList.map((rec) => {
const isPlaying = recorderState === 'playing';
const durationSec = Math.round(rec.durationMs / 1000);
const durationLabel = durationSec >= 60
? `${Math.floor(durationSec / 60)}${durationSec % 60}`
: `${durationSec}`;
const dateLabel = new Date(rec.recordedAt).toLocaleString('ja-JP', {
month: 'numeric', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
const recordingRow = (
<TouchableOpacity
onPress={() => startPlayback(rec.id)}
disabled={isPlaying}
activeOpacity={0.72}
style={{
flexDirection: 'row', alignItems: 'center',
backgroundColor: colors.backgroundSecondary,
borderRadius: 8, padding: 12, gap: 10,
opacity: isPlaying ? 0.6 : 1,
}}
>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: 'bold' }}>
{dateLabel}
</Text>
<Text style={{ color: colors.textSecondary, fontSize: 11 }}>
{rec.snapshotCount} / {durationLabel}
</Text>
</View>
<View style={{ alignItems: 'flex-end', gap: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<MaterialCommunityIcons
name={isPlaying ? 'pause-circle-outline' : 'play-circle-outline'}
size={18}
color={isPlaying ? colors.textTertiary : '#43a047'}
/>
<Text
style={{
color: isPlaying ? colors.textTertiary : colors.textPrimary,
fontSize: 12,
fontWeight: '600',
}}
>
{isPlaying ? '再生中は操作不可' : 'タップで再生'}
</Text>
</View>
{!isPlaying && (
<Text style={{ color: colors.textTertiary, fontSize: 10 }}>
</Text>
)}
</View>
<MaterialCommunityIcons
name="chevron-right"
size={18}
color={colors.iconSecondary}
/>
</TouchableOpacity>
);
if (isPlaying) {
return <View key={rec.id}>{recordingRow}</View>;
}
return (
<Swipeable
key={rec.id}
ref={(instance) => {
recordingSwipeRefs.current[rec.id] = instance;
}}
friction={2}
overshootRight={false}
rightThreshold={48}
renderRightActions={() => (
<View
style={{
width: 96,
borderRadius: 8,
backgroundColor: '#e53935',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 6,
}}
>
<MaterialCommunityIcons name="trash-can-outline" size={18} color="#fff" />
<Text style={{ color: '#fff', fontSize: 11, fontWeight: 'bold', marginTop: 4 }}>
</Text>
</View>
)}
onSwipeableOpen={() => confirmDeleteRecording(rec.id, dateLabel)}
>
{recordingRow}
</Swipeable>
);
})}
</View>
)}
</View>
<View
style={[
styles.debugSection,
@@ -0,0 +1,528 @@
import React, { useRef, useState } from "react";
import {
Alert,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import Swipeable from "react-native-gesture-handler/Swipeable";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useNotification } from "@/stateBox/useNotifications";
export const ResearchToolsSettings = () => {
const navigation = useNavigation<any>();
const { colors, fixed } = useThemeColors();
const { expoPushToken } = useNotification();
const {
updatePermission,
mockApiFeatureEnabled,
setMockApiFeatureEnabled,
recorderState,
recordingSnapshotCount,
recordingList,
startRecording,
stopRecording,
startPlayback,
stopPlayback,
deleteRecording,
exportRecordingFile,
exportAllRecordingsFile,
importRecordingFile,
} = useTrainMenu();
const showResearchTools = __DEV__ || updatePermission;
const recordingSwipeRefs = useRef<Record<string, { close: () => void } | null>>({});
const [recordingFileStatus, setRecordingFileStatus] = useState<{
type: "info" | "success" | "error";
text: string;
}>({ type: "info", text: "録画JSONの書き出しと読み込みができます。" });
const closeRecordingSwipe = (id: string) => {
recordingSwipeRefs.current[id]?.close();
};
const confirmDeleteRecording = (id: string, label: string) => {
closeRecordingSwipe(id);
Alert.alert("録画を削除", `${label} の録画を削除しますか?`, [
{ text: "キャンセル", style: "cancel" },
{
text: "削除",
style: "destructive",
onPress: () => {
void deleteRecording(id);
},
},
]);
};
const setFileStatus = (type: "info" | "success" | "error", text: string) => {
setRecordingFileStatus({ type, text });
};
const handleExportRecordingFile = async (id: string, label: string) => {
try {
await exportRecordingFile(id);
setFileStatus("success", `${label} の録画JSONを書き出しました。`);
} catch (error) {
setFileStatus(
"error",
`録画JSONを書き出せませんでした: ${(error as Error).message}`,
);
}
};
const handleExportAllRecordingsFile = async () => {
try {
await exportAllRecordingsFile();
setFileStatus("success", `${recordingList.length}件の録画JSONを書き出しました。`);
} catch (error) {
setFileStatus(
"error",
`録画JSONを書き出せませんでした: ${(error as Error).message}`,
);
}
};
const handleImportRecordingFile = async () => {
try {
const result = await importRecordingFile();
if (!result) {
setFileStatus("info", "録画JSONの読み込みをキャンセルしました。");
return;
}
setFileStatus(
"success",
result.overwrittenCount > 0
? `${result.importedCount}件を読み込みました。${result.overwrittenCount}件は同じIDのため上書きしました。`
: `${result.importedCount}件を読み込みました。`,
);
} catch (error) {
setFileStatus(
"error",
`録画JSONを読み込めませんでした: ${(error as Error).message}`,
);
}
};
const openRecordingDatabase = () => {
const uri = `https://experimental.shikoku-railinfo.haruk.in/position-board?from=eachTrainInfo&userID=${encodeURIComponent(expoPushToken || "")}`;
const params = {
uri,
importRecordingDownloads: true,
useExitButton: false,
};
const parentNavigation = navigation.getParent?.();
if (parentNavigation) {
parentNavigation.navigate("generalWebView", params);
return;
}
navigation.navigate("generalWebView", params);
};
return (
<View style={[styles.container, { backgroundColor: fixed.primary }]}>
<SheetHeaderItem
title="調査ツール"
LeftItem={{
title: " 戻る",
onPress: () => navigation.goBack(),
position: "left",
}}
/>
<ScrollView
style={[styles.content, { backgroundColor: colors.backgroundSecondary }]}
contentContainerStyle={styles.contentInner}
>
{!showResearchTools ? (
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>調</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>調</Text>
</View>
) : (
<>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: モックAPI検証</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}></Text>
<View style={styles.switchRow}>
<Text style={[styles.debugCurrentText, { color: colors.textPrimary, fontSize: 14 }]}>API検証機能</Text>
<Switch
value={mockApiFeatureEnabled}
onValueChange={setMockApiFeatureEnabled}
color={fixed.primary}
/>
</View>
</View>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: 走行位置録画</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>OFFになります</Text>
<View style={styles.statusRow}>
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor:
recorderState === "recording"
? "#e53935"
: recorderState === "playing"
? "#43a047"
: colors.borderSecondary,
}}
/>
<Text style={[styles.debugCurrentText, { color: colors.textSecondary, fontSize: 13 }]}>
{recorderState === "recording"
? `録画中… ${recordingSnapshotCount} スナップショット`
: recorderState === "playing"
? "再生中"
: `${recordingList.length} 件の録画`}
</Text>
</View>
<View style={styles.buttonRow}>
{recorderState === "idle" && (
<TouchableOpacity onPress={startRecording} style={styles.recordButton}>
<Text style={styles.primaryButtonText}> </Text>
</TouchableOpacity>
)}
{recorderState === "recording" && (
<TouchableOpacity onPress={stopRecording} style={[styles.neutralButton, { backgroundColor: colors.borderSecondary }]}>
<Text style={[styles.neutralButtonText, { color: colors.textPrimary }]}> </Text>
</TouchableOpacity>
)}
{recorderState === "playing" && (
<TouchableOpacity onPress={stopPlayback} style={[styles.neutralButton, { backgroundColor: colors.borderSecondary }]}>
<Text style={[styles.neutralButtonText, { color: colors.textPrimary }]}> </Text>
</TouchableOpacity>
)}
</View>
<View style={[styles.fileBox, { backgroundColor: colors.backgroundTertiary }]}>
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: "600" }}>JSONファイル</Text>
<View style={styles.buttonRow}>
<TouchableOpacity
onPress={() => {
void handleImportRecordingFile();
}}
disabled={recorderState === "recording"}
style={{
backgroundColor: recorderState === "recording" ? colors.borderSecondary : fixed.primary,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
opacity: recorderState === "recording" ? 0.6 : 1,
}}
>
<Text style={{ color: fixed.textOnPrimary, fontWeight: "bold", fontSize: 12 }}>JSONを読み込む</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
void handleExportAllRecordingsFile();
}}
disabled={recordingList.length === 0 || recorderState === "recording"}
style={{
backgroundColor:
recordingList.length === 0 || recorderState === "recording"
? colors.borderSecondary
: colors.surface,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.borderSecondary,
paddingHorizontal: 12,
paddingVertical: 8,
opacity: recordingList.length === 0 || recorderState === "recording" ? 0.6 : 1,
}}
>
<Text style={{ color: colors.textPrimary, fontWeight: "bold", fontSize: 12 }}></Text>
</TouchableOpacity>
</View>
<View
style={{
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
backgroundColor:
recordingFileStatus.type === "success"
? "#43a04722"
: recordingFileStatus.type === "error"
? "#e5393522"
: colors.surface,
borderWidth: 1,
borderColor:
recordingFileStatus.type === "success"
? "#43a04755"
: recordingFileStatus.type === "error"
? "#e5393555"
: colors.borderSecondary,
}}
>
<Text style={{ color: colors.textSecondary, fontSize: 12, lineHeight: 18 }}>{recordingFileStatus.text}</Text>
</View>
</View>
{recordingList.length > 0 && recorderState !== "recording" && (
<View style={styles.recordingList}>
{recordingList.map((rec) => {
const isPlaying = recorderState === "playing";
const durationSec = Math.round(rec.durationMs / 1000);
const durationLabel = durationSec >= 60
? `${Math.floor(durationSec / 60)}${durationSec % 60}`
: `${durationSec}`;
const dateLabel = new Date(rec.recordedAt).toLocaleString("ja-JP", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const recordingRow = (
<TouchableOpacity
onPress={() => startPlayback(rec.id)}
onLongPress={() => {
void handleExportRecordingFile(rec.id, dateLabel);
}}
disabled={isPlaying}
activeOpacity={0.72}
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.backgroundSecondary,
borderRadius: 8,
padding: 12,
gap: 10,
opacity: isPlaying ? 0.6 : 1,
}}
>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: "bold" }}>{dateLabel}</Text>
<Text style={{ color: colors.textSecondary, fontSize: 11 }}>{rec.snapshotCount} / {durationLabel}</Text>
</View>
<View style={{ alignItems: "flex-end", gap: 4 }}>
<View style={styles.playbackHint}>
<MaterialCommunityIcons
name={isPlaying ? "pause-circle-outline" : "play-circle-outline"}
size={18}
color={isPlaying ? colors.textTertiary : "#43a047"}
/>
<Text
style={{
color: isPlaying ? colors.textTertiary : colors.textPrimary,
fontSize: 12,
fontWeight: "600",
}}
>
{isPlaying ? "再生中は操作不可" : "タップで再生"}
</Text>
</View>
{!isPlaying && (
<Text style={{ color: colors.textTertiary, fontSize: 10 }}> / </Text>
)}
</View>
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.iconSecondary} />
</TouchableOpacity>
);
if (isPlaying) {
return <View key={rec.id}>{recordingRow}</View>;
}
return (
<Swipeable
key={rec.id}
ref={(instance) => {
recordingSwipeRefs.current[rec.id] = instance;
}}
friction={2}
overshootRight={false}
rightThreshold={48}
renderRightActions={() => (
<View style={styles.deleteAction}>
<MaterialCommunityIcons name="trash-can-outline" size={18} color="#fff" />
<Text style={styles.deleteActionText}></Text>
</View>
)}
onSwipeableOpen={() => confirmDeleteRecording(rec.id, dateLabel)}
>
{recordingRow}
</Swipeable>
);
})}
</View>
)}
<TouchableOpacity
onPress={openRecordingDatabase}
activeOpacity={0.78}
style={[
styles.databaseButton,
{
backgroundColor: fixed.primary,
borderColor: fixed.primary,
},
]}
>
<MaterialCommunityIcons
name="database-search-outline"
size={18}
color={fixed.textOnPrimary}
/>
<Text style={[styles.databaseButtonText, { color: fixed.textOnPrimary }]}>
</Text>
</TouchableOpacity>
</View>
</>
)}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0099CC",
},
content: {
flex: 1,
backgroundColor: "#f8f8fc",
},
contentInner: {
paddingHorizontal: 14,
paddingBottom: 40,
paddingTop: 20,
gap: 12,
},
debugSection: {
borderRadius: 12,
borderWidth: 1,
padding: 14,
gap: 10,
},
debugTitle: {
fontSize: 15,
fontWeight: "bold",
},
debugDescription: {
fontSize: 12,
lineHeight: 18,
},
debugCurrentText: {
fontSize: 11,
lineHeight: 16,
},
switchRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 8,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
marginTop: 8,
gap: 8,
},
buttonRow: {
flexDirection: "row",
gap: 8,
marginTop: 10,
flexWrap: "wrap",
},
recordButton: {
backgroundColor: "#e53935",
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 8,
},
neutralButton: {
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 8,
},
primaryButtonText: {
color: "#fff",
fontWeight: "bold",
fontSize: 13,
},
neutralButtonText: {
fontWeight: "bold",
fontSize: 13,
},
fileBox: {
marginTop: 4,
gap: 8,
borderRadius: 8,
padding: 10,
},
recordingList: {
marginTop: 10,
gap: 6,
},
playbackHint: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
deleteAction: {
width: 96,
borderRadius: 8,
backgroundColor: "#e53935",
alignItems: "center",
justifyContent: "center",
marginLeft: 6,
},
deleteActionText: {
color: "#fff",
fontSize: 11,
fontWeight: "bold",
marginTop: 4,
},
databaseButton: {
minHeight: 46,
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
marginTop: 4,
},
databaseButtonText: {
fontSize: 14,
fontWeight: "bold",
},
});
+11
View File
@@ -16,6 +16,7 @@ import { SwitchArea } from "../atom/SwitchArea";
import { useNotification } from "../../stateBox/useNotifications";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
const versionCode = "7.0.7"; // Update this version code as needed
@@ -29,6 +30,8 @@ export const SettingTopPage = ({
const { expoPushToken } = useNotification();
const { colors, fixed, colorTheme, setColorTheme } = useThemeColors();
const navigation = useNavigation<any>();
const { updatePermission } = useTrainMenu();
const showResearchTools = __DEV__ || updatePermission;
return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
@@ -141,6 +144,14 @@ export const SettingTopPage = ({
navigation.navigate("setting", { screen: "DataSourceSettings" })
}
/>
{showResearchTools && (
<SettingList
string="調査ツール"
onPress={() =>
navigation.navigate("setting", { screen: "ResearchToolsSettings" })
}
/>
)}
<SectionHeader title="その他" />
<SettingList
+22 -10
View File
@@ -10,7 +10,7 @@ import {
ToastAndroid,
} from "react-native";
import { createStackNavigator } from "@react-navigation/stack";
import { TransitionPresets } from "@react-navigation/stack";
import { pushTransitionOptions } from "@/lib/stackOption";
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
import * as Updates from "expo-updates";
import { AS } from "../../storageControl";
@@ -22,6 +22,7 @@ import { FavoriteSettings } from "./FavoriteSettings";
import { NotificationSettings } from "./NotificationSettings";
import { LauncherIconSettings } from "./LauncherIconSettings";
import { DataSourceSettings } from "./DataSourceSettings";
import { ResearchToolsSettings } from "./ResearchToolsSettings";
import { FelicaHistoryPage } from "./FelicaHistoryPage";
import { SoundSettings } from "./SoundSettings";
import { OperationInfoSettings } from "./OperationInfoSettings";
@@ -112,7 +113,7 @@ export default function Setting(props) {
name="settingTopPage"
options={{
gestureEnabled: false,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -132,7 +133,7 @@ export default function Setting(props) {
name="LayoutSettings"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -165,7 +166,7 @@ export default function Setting(props) {
name="NotificationSettings"
options={{
//gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -176,7 +177,7 @@ export default function Setting(props) {
name="LauncherIconSettings"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -187,7 +188,7 @@ export default function Setting(props) {
name="FavoriteSettings"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -198,18 +199,29 @@ export default function Setting(props) {
name="DataSourceSettings"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
}}
component={DataSourceSettings}
/>
<Stack.Screen
name="ResearchToolsSettings"
options={{
gestureEnabled: true,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
}}
component={ResearchToolsSettings}
/>
<Stack.Screen
name="FelicaHistoryPage"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -220,7 +232,7 @@ export default function Setting(props) {
name="SoundSettings"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -231,7 +243,7 @@ export default function Setting(props) {
name="OperationInfoSettings"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
+1 -1
View File
@@ -1,5 +1,5 @@
import React, { FC } from "react";
import { Image, StyleSheet, View } from "react-native";
import { Image, StyleSheet, View, Platform } from "react-native";
import { Marker } from "react-native-maps";
import { useNavigation } from "@react-navigation/native";
import { useStationList } from "@/stateBox/useStationList";
+58
View File
@@ -0,0 +1,58 @@
# 保存した運用録画のファイルインポート/エクスポート方針
## 目的
保存した走行位置録画を、クリップボードだけでなくファイルとして受け渡しできるようにする。
録画データを JSON として書き出し、同じ JSON を選択して取り込める構成にする。
## 導入済みの必須ライブラリ
- `expo-file-system`: 録画 JSON を一時ファイルまたは Documents 配下へ書き出し、インポート時に選択済みファイルを読み込む。
- `expo-document-picker`: ユーザーに録画 JSON ファイルを選ばせる。読み込み直後に扱いやすくするため、実装時は `copyToCacheDirectory: true` を使う。
既存の `expo-sharing` は単一ファイルの共有 fallback として残す。`react-native-share` は複数画像や複数ファイルの同時共有、共有先ごとの制御が必要なケースで使う。既存の `expo-clipboard` はコピペ export/import の fallback として残す。
## 実装済み
- 録画1件と全件の JSON envelope 書き出し。
- `react-native-share` によるネイティブ共有/保存。
- Web では `Blob``download` による JSON ダウンロード。
- `expo-document-picker``expo-file-system` による JSON 読み込み。
- 既存保存形式と同じ検証処理を通したインポート。
- 録画本体は `Paths.document/train-recordings` 配下の JSON ファイルへ保存し、AsyncStorage/SQLite には軽量な一覧メタだけ保存する。
- 旧SQLite保存の録画本体は、起動時/インポート時にファイルへ移行して旧キーを削除する。
## 想定する実装方針
### ネイティブ
1. 録画データを version 付き envelope JSON に変換する。
2. `expo-file-system``cacheDirectory``.json` ファイルを書き出す。
3. 単一 JSON は `expo-sharing` または `react-native-share` で共有する。
4. 複数画像や複数ファイルを同時共有する場合は `react-native-share``urls` を使う。
5. インポートは `expo-document-picker` で JSON を選択する。
6. 選択したファイルを `expo-file-system` で読み込み、既存の検証処理を通して保存する。
### Web
Web ではローカル file URI の `expo-sharing` が使えないため、`Blob``URL.createObjectURL` による download を別実装にする。
インポートはブラウザの file input 相当で読み込む方針にする。
## 正式採用する共有ライブラリ
- `react-native-share`: 複数画像や複数ファイルを同時共有するために正式採用する。Expo config plugin は options 付きで登録し、初期状態では `ios``android` の query 追加を空配列にしている。特定アプリ向けの `shareSingle``isPackageInstalled` が必要になった時点で、対象アプリの scheme/package を追加する。
- `enableBase64ShareAndroid`: 初期状態では `false`。ファイル URI 共有を基本にするため、Android の legacy storage permission を増やさない。base64 共有が必要になった場合だけ有効化する。
## 今回インストールしない候補
- `pako` または `fflate`: 録画 JSON が大きくなったときの gzip/deflate 圧縮候補。まずは plain JSON で運用し、共有できないサイズになった時点で入れる。
- `expo-file-system` config plugin options: iOS の Files アプリからアプリ Documents を直接見せたい場合に `enableFileSharing``supportsOpeningDocumentsInPlace` を検討する。現時点では共有シートと DocumentPicker で足りる。
- `expo-sharing` の共有受け取り設定: 他アプリから「このアプリで開く」導線を作る候補。まずはアプリ内のインポートボタンから選択する方式にする。
- `react-native-blob-util`: 大容量/バックグラウンド/Android Storage Access Framework の細かい制御が必要になった場合の候補。Expo managed workflow では導入コストが上がるため、現時点では使わない。
## 参考
- https://docs.expo.dev/versions/latest/sdk/filesystem/
- https://docs.expo.dev/versions/latest/sdk/document-picker/
- https://docs.expo.dev/versions/latest/sdk/sharing/
@@ -0,0 +1,258 @@
# Sentry Crash 調査履歴
## 目的
このメモは、現在追っているクラッシュ調査の経緯を ChatGPT 側で再考察しやすい形に整理したもの。
## 現象
- `top menu` を表示した後、タブ切り替えや回転をきっかけにクラッシュする。
- Android / iOS の両方で再現した。
- 既存の `lazy` / `detachInactiveScreens` は必須前提として扱う。
- 走行位置、運行情報、トップメニューの各画面で WebView / ネイティブ View のマウントが絡んでいる。
## 最初の主要シグナル
- Sentry の主エラーは `RetryableMountingLayerException: Unable to find viewState for tag ...`
- 典型的には Fabric / New Architecture のネイティブ UI ツリー更新失敗を示していた。
- つまり JavaScript の例外というより、View の mount / unmount タイミング競合を疑うべき状態だった。
## 試した方向性の履歴
### 1. 遷移タイミングをずらす
- `navigate` / `replace` / `reset` の直後に落ちるケースを疑い、遷移を一拍遅らせる案を検討した。
- ただし症状は完全には止まらず、遷移先だけの問題ではなかった。
### 2. ライフサイクル依存の自動解放を抑える
- iOS でブラウザや WebView がタブ切り替え時に開放される問題があり、`lazyload` を外す方向は破綻した。
- そのため、WebView を「常時破棄しない」方向を維持する必要があった。
### 3. Sentry の観測点を増やす
- 画面ごとに breadcrumb と context を追加した。
- 追加した主な観測点:
- `Apps.tsx`
- `Top.tsx`
- `components/Apps.tsx`
- `components/Apps/WebView.tsx`
- `MenuPage.tsx`
- `ndView.tsx`
- `lib/useWebViewRemount.ts`
- これで「どの画面がいつ active になったか」「WebView がいつ remount されたか」を追えるようにした。
## 重要な発見
### 発見 1: 走行位置画面の WebView が、非表示なのに active 扱いになる
- Sentry のイベントで、`root_tab = topMenu` なのに `positions_screen.activatedScreen = true``positions_webview.currentUrl = https://train.jr-shikoku.co.jp/sp.html` が見えていた。
- Replay 上はトップメニューだけに見えても、内部 state は走行位置側が先に活性化していた。
- これは「見た目では隠れているが、React Navigation / Fabric 的には既に mount されている」可能性を示した。
### 発見 2: `useIsFocused()` を初期 render の active 判定に使うのが危険
- `useIsFocused()` 由来の初期状態が、ナビゲータ初期化直後に想定外の `true` を返す経路があった。
- その結果、`positions` 側の root / stack / screen / webview が早すぎるタイミングで活性化した。
- これが Fabric の mount 競合を起こしている可能性が高い。
### 発見 3: `positions` の gate を 1 枚かませても足りなかった
- `DeferredPositionsRoot` を追加して root 側を遅延させたが、それでも crash は残った。
- つまり root だけではなく、stack / screen / WebView 側にも同じ「初期 focused 扱い」の問題が潜んでいた。
## 反復した修正
### 実施した instrumentation
- root navigation の state 変化を breadcrumb に残すようにした。
- `positions_stack` / `positions_screen` / `positions_webview` / `operation_screen` などの context を追加した。
- WebView remount の理由も記録するようにした。
### 実施した防御
- 画面遷移時の WebView remount を観測しやすくした。
- Android の回転時やバックグラウンド遷移時に何が起きるか追えるようにした。
## 現在の仮説
- 根本原因は React Navigation そのものではなく、初期 render 時の `focused` 判定を前提にした activation が早すぎること。
- その結果、非表示の `positions` ツリーが先に active になり、Fabric が mount state を失う。
- 画面遷移先の問題というより、「起動直後の数タイミングで走行位置を経由したタブ遷移が入ると壊れる」挙動に近い。
## upstream issue 調査メモ
- `software-mansion/react-native-reanimated#9785`
- `open`
- Android + Fabric + React Navigation stack animation 有効時に `SurfaceMountingManager` で crash。
- Android だけ stack animation を `none` にすると止まる、という回避策まで一致している。
- `software-mansion/react-native-reanimated#6908`
- `closed`
- Android Fabric で `entering/exiting animations` 使用時に大量 crash。
- 本文中に `RetryableMountingLayerException: Unable to find viewState for tag ... Surface stopped: false` があり、Sentry の症状にかなり近い。
- `software-mansion/react-native-reanimated#9636`
- `closed`
- `NativeProxy.preserveMountedTags` が Fabric の view preallocation 中に `Unable to find view for tag ... Surface stopped: false` で落ちる。
- 重い native view が同期イベントを投げると crash しやすいという話で、WebView / map 系にも連想が効く。
- `software-mansion/react-native-reanimated#8344`
- `open`
- multi-surface で `LayoutAnimationsProxy` が surface aware でない、という報告。
- 直接 Android の同一事象ではないが、「surface をまたぐ animation / mount transaction が壊れる」という意味で構造が近い。
## 現時点での対策方針
- Android の React Navigation stack animation は無効化する。
- Android の Reanimated `entering/exiting/layout` は、実際に使っている箇所から順に止めて再現率を下げる。
- 現在 repo 内で確認できた該当箇所は `components/Menu/Carousel/CarouselBox.tsx``FadeIn/FadeOut`
## 直近の修正
- `Apps.tsx`
- `DeferredPositionsRoot` の activation 初期値を `false` に変更。
- `Top.tsx`
- positions stack の activation 初期値を `false` に変更。
- `components/Apps.tsx`
- positions screen / WebView の activation 初期値を `false` に変更。
- `ndView.tsx`
- operation WebView の activation 初期値を `false` に変更。
## 直近の OTA
- branch: `gmarket`
- message: `initialize tab activation gates from false`
- update group ID: `82552333-cb5e-455f-ac58-65ada37f7acd`
- Android update ID: `019f2133-c3e6-7050-911c-49052b6b17a1`
- iOS update ID: `019f2133-c3e6-79b9-9776-88fd55ff72b8`
- commit: `cf3995fcbe34366b32b09ee1cfb6b27a7e42982c`
## いま ChatGPT に見てほしい論点
1. 初期 focused 判定を `false` にしても crash が続くなら、次はどの画面の mount ordering を疑うべきか。
2. `positions` ツリーのどこまでが本当に先に active になるのかを、Sentry context と replay でどう切り分けるか。
3. Fabric / New Architecture の mount race として扱うべきか、React Navigation の state propagation 問題として扱うべきか。
## 参考ファイル
- [Apps.tsx](/home/ubuntu/jrshikoku/Apps.tsx)
- [Top.tsx](/home/ubuntu/jrshikoku/Top.tsx)
- [components/Apps.tsx](/home/ubuntu/jrshikoku/components/Apps.tsx)
- [components/Apps/WebView.tsx](/home/ubuntu/jrshikoku/components/Apps/WebView.tsx)
- [ndView.tsx](/home/ubuntu/jrshikoku/ndView.tsx)
- [lib/useWebViewRemount.ts](/home/ubuntu/jrshikoku/lib/useWebViewRemount.ts)
## 現在の安定化パッチの意味
- 最新の `gmarket` 配信では、クラッシュ回避のために非表示タブの重い native view をかなり積極的に止めている。
- その結果、クラッシュは減った一方で、以下の UX 劣化が意図的に入っている。
- 走行位置 / 運行情報の WebView がタブ離脱後に再初期化されやすい
- タブ間で状態保持されず、開き直しに見える
- 動的ルーティングやバックグラウンド継続性が弱くなっている
- トップメニューの重い要素は遷移直後に即 mount されない
## 現在の安定基準点
- `gmarket` で配信済みの `topMenu` heavy mount 遅延パッチでは、少なくとも直近の再現テストでクラッシュ頻度は大きく下がっている。
- したがって、ここを一旦「クラッシュしにくい基準点」として扱い、以降は UX を壊している防御を一つずつ戻していく方針が妥当。
- この時点で重要なのは「全部戻す」ことではなく、「どの防御を戻すとクラッシュが再発するか」を Sentry で切り分けること。
## UX 劣化の主因になっている防御
- `components/Apps.tsx`
- `positions` の WebView を blur 時に teardown / deactivate している。
- `Apps.tsx` / `Top.tsx` / `lib/rootNavigation.ts`
- `positions` タブ離脱時に root / stack 側も落とす寄りの制御を入れている。
- `ndView.tsx`
- `information` の WebView も blur 中は deactivate している。
- `menu.tsx`
- `topMenu``MapView` / `CarouselBox` / `LED_vision` を Android で遅延 mount している。
## ここから戻す優先順位
1. `positions` の blur 時 WebView teardown を部分的に戻す
- 対象: `components/Apps.tsx`
- 理由: 走行位置の開き直し、状態喪失、バックグラウンド継続切断に最も効いているため。
2. `positions` の tab blur 時 stack / root teardown を緩める
- 対象: `Apps.tsx`, `Top.tsx`, `lib/rootNavigation.ts`
- 理由: タブ間データ共有、動的ルーティング、navigation state 維持の破綻に直結しているため。
3. `information` の blur 時 WebView teardown を戻す
- 対象: `ndView.tsx`
- 理由: 運行情報の開き直しや iOS 側の裏ブラウザ保持に関係するため。
4. `topMenu` の heavy mount 遅延を必要最小限まで縮める
- 対象: `menu.tsx`
- 理由: 初回表示のもたつき改善には効くが、状態維持より優先度は下。
5. 最終的に teardown をやめて pause ベースに置き換える
- 対象: `components/Apps.tsx`, `ndView.tsx` など
- 理由: 安定性を維持しながら UX を戻す本命だが、切り分け後にやるべき段階。
## 次の実施方針
- まず `positions` を中心に戻す。
- 1 回の変更で戻す範囲は狭くし、再現したらその差分だけを Sentry で見る。
- つまり今後の作業は「クラッシュ回避策の全撤廃」ではなく、「UX を壊している要素を優先順に切り戻し、再発ラインを特定する」ことが目的。
## 2026-07-03 時点の基準点
### 現在の評価
- 起動直後の `positions -> topMenu` / `positions -> information` で高頻度に落ちていた主クラッシュは、現時点ではかなり再現率が下がっている。
- 少なくとも直近の観測では、以前の `RetryableMountingLayerException` 系の大崩れは前面には出ていない。
- そのため、この時点は「主問題をかなり抑え込めた基準点」として保存する価値がある。
### この時点で残っている別系統の問題
- `JR-SHIKOKU-UNOFFICIAL-APPS-S`
- `Error: Unsupported top level event type "topUserLocationChange" dispatched`
- `root_tab = positions`
- `level = fatal`
- `ReactFabric-prod` 起点
- これは、今回の `topMenu / information / hidden prewarm` 系の問題とは別系統とみなす。
- つまり、この時点での主クラッシュ対策の評価をする際は、この issue は切り離して考える。
### この時点での差分の分類
#### A. 主クラッシュ抑止のコア差分
以下は、現時点の安定化に本質的に効いている可能性が高く、安易に戻さない。
- `Apps.tsx`
- `DeferredPositionsRoot`
- `hasVisitedPositions`
- startup delayed prewarm
- `detachInactiveScreens={false}`
- root tab の `positionsLifecycleRef` 連携
- `Top.tsx`
- `positions stack` の background prewarm
- stack activation gate
- `components/Apps.tsx`
- `positions screen` / `WebView` の activation gate
- `hasStableWebViewSession`
- blur 時 preserve
- unstable session 中の tab exit guard
- `components/Apps/WebView.tsx`
- WebView 初回安定化通知
- message / load / process termination の監視強化
- `lib/rootNavigation.ts`
- `positionsLifecycleRef`
- `lib/useWebViewRemount.ts`
- remount reason 付きの共通制御
#### B. 補助的な安定化差分
以下は、主因とは言い切れないが、再現率低下に寄与している可能性がある。
- `menu.tsx`
- top menu heavy content の delayed activation
- Android で `MapView` や重い要素をすぐ mount しない制御
- `ndView.tsx`
- operation WebView の delayed activation
- operation 側 remount / parse / settings load の観測
- `lib/stackOption.ts`
- Android で push animation を切る変更
- `components/Settings/settings.tsx`
- settings stack へ共通 animation option を適用
#### C. 観測専用で後から削れる差分
以下は、挙動改善というより Sentry で成功/失敗比較を行うための記録であり、安定化の評価が済めば削減可能。
- `Apps.tsx``nav.root` / `nav.tab` / `positions.root` breadcrumb と context の大半
- `Top.tsx``positions.stack` breadcrumb / context
- `components/Apps.tsx``positions.screen` breadcrumb / context
- `components/Apps/WebView.tsx``positions.webview` breadcrumb / context
- `MenuPage.tsx``topMenu.screen` breadcrumb / context
- `menu.tsx``top_menu_runtime` breadcrumb / context
- `ndView.tsx``operation.screen` / `operation.settings` / `operation.webview` breadcrumb / context
- `components/Menu/Carousel/CarouselBox.tsx``menu.carousel` breadcrumb
### この時点での重要な判断
- 今の差分は広いが、全部が同じ重みではない。
- まず守るべきなのは `positions` の activation / preserve / unstable guard 周辺であり、ここが今回の基礎。
- 一方、Sentry breadcrumb の大半は観測用なので、安定化判断後にかなり整理できる。
- `CarouselBox.tsx` は特に「Android fallback ロジック」と「breadcrumb」の両方が混ざっているため、後で整理する際は分離して扱う。
### 今後の戻し方の基準
- 戻すときは、まず観測用差分からではなく、`UX を壊しているがコアではない差分` を対象にする。
- つまり順番としては:
1. 観測を維持したまま補助差分を少し戻す
2. 再発しないことを確認する
3. 最後に breadcrumb / context を掃除する
- コア差分を先に剥がすと、再び「どこで壊れたか分からない状態」に戻るので避ける。
### この時点の結論
- `positions` の hidden / delayed activation と lifecycle guard 群は、今回の主クラッシュ対策の核として維持する。
- `topMenu` / `operation` / `carousel` 周辺の差分は、主因ではなく補助あるいは観測の可能性があるため、今後の整理対象にできる。
- 新たに見つかった `topUserLocationChange` は別問題であり、この基準点の評価には混ぜない。
+6
View File
@@ -47,6 +47,12 @@
},
"production7.0": {
"channel": "familymart"
},
"beta7.1": {
"channel": "gmarket"
},
"production7.1": {
"channel": "geekbuying"
}
},
"submit": {
+291 -14
View File
@@ -1,3 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Directory, File, Paths } from 'expo-file-system';
import { AS } from '../../storageControl';
import { STORAGE_KEYS } from '../../constants/storage';
import { TrainEntry } from './webviewXhrInterceptor';
@@ -24,6 +27,60 @@ export type TrainRecording = {
snapshots: TrainSnapshot[];
};
export type RecordingExportEnvelope = {
format: 'jrshikoku-train-recording';
version: 1;
exportedAt: string;
recording: TrainRecording;
};
export type RecordingsExportEnvelope = {
format: 'jrshikoku-train-recordings';
version: 1;
exportedAt: string;
recordings: TrainRecording[];
};
export type RecordingImportResult = {
importedCount: number;
overwrittenCount: number;
};
const RECORDING_EXPORT_FORMAT = 'jrshikoku-train-recording' as const;
const RECORDINGS_EXPORT_FORMAT = 'jrshikoku-train-recordings' as const;
const RECORDING_FILE_DIRECTORY = 'train-recordings';
const getRecordingsDirectory = () => new Directory(Paths.document, RECORDING_FILE_DIRECTORY);
const ensureRecordingsDirectory = () => {
const directory = getRecordingsDirectory();
directory.create({ idempotent: true, intermediates: true });
return directory;
};
const getRecordingFileName = (id: string) => `${encodeURIComponent(id)}.json`;
const getRecordingFile = (id: string, createDirectory = false) =>
new File(createDirectory ? ensureRecordingsDirectory() : getRecordingsDirectory(), getRecordingFileName(id));
const getLegacyRecordingStorageKey = (id: string) =>
(STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as string;
const removeLegacyRecordingStorage = async (id: string) => {
const key = getLegacyRecordingStorageKey(id);
await AS.removeItem(key).catch(() => {});
};
const cleanupLegacyRecordingStorage = async () => {
const prefix = STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX;
const keys = await AsyncStorage.getAllKeys().catch(() => []);
await Promise.all(
keys
.filter((key) => key.startsWith(prefix))
.map((key) => AsyncStorage.removeItem(key).catch(() => {})),
);
};
const parse = <T>(raw: string | null | boolean): T | null => {
if (!raw) return null;
try {
@@ -37,6 +94,99 @@ const parse = <T>(raw: string | null | boolean): T | null => {
export const generateRecordingId = (recordedAt: string) =>
recordedAt.replace(/[:.]/g, '-');
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const normalizeDirection = (value: unknown): 0 | 1 => (value === 1 ? 1 : 0);
const normalizeDelay = (value: unknown): number | string =>
typeof value === 'number' || typeof value === 'string' ? value : 0;
const normalizeTrainEntry = (value: unknown): TrainEntry | null => {
if (!isPlainObject(value)) return null;
if (typeof value.TrainNum !== 'string') return null;
if (typeof value.Line !== 'string') return null;
if (typeof value.Type !== 'string') return null;
return {
Index: typeof value.Index === 'number' ? value.Index : 0,
TrainNum: value.TrainNum,
Pos: typeof value.Pos === 'string' ? value.Pos : '',
PosNum: typeof value.PosNum === 'number' ? value.PosNum : 0,
delay: normalizeDelay(value.delay),
Direction: normalizeDirection(value.Direction),
Type: value.Type,
Line: value.Line,
};
};
const normalizeSnapshot = (value: unknown): TrainSnapshot | null => {
if (!isPlainObject(value)) return null;
if (!Array.isArray(value.trains)) return null;
const trains = value.trains
.map(normalizeTrainEntry)
.filter((entry): entry is TrainEntry => entry !== null);
if (trains.length !== value.trains.length) return null;
return {
t: typeof value.t === 'number' && Number.isFinite(value.t) ? Math.max(0, value.t) : 0,
trains,
};
};
const normalizeRecording = (value: unknown): TrainRecording | null => {
if (!isPlainObject(value)) return null;
if (typeof value.recordedAt !== 'string') return null;
if (!Array.isArray(value.snapshots)) return null;
const snapshots = value.snapshots
.map(normalizeSnapshot)
.filter((snapshot): snapshot is TrainSnapshot => snapshot !== null);
if (snapshots.length !== value.snapshots.length) return null;
const durationMs =
typeof value.durationMs === 'number' && Number.isFinite(value.durationMs)
? Math.max(0, value.durationMs)
: snapshots[snapshots.length - 1]?.t ?? 0;
const id =
typeof value.id === 'string' && value.id.trim().length > 0
? value.id
: generateRecordingId(value.recordedAt);
return {
id,
recordedAt: value.recordedAt,
durationMs,
snapshots,
};
};
const extractRecordingsFromImport = (value: unknown): TrainRecording[] => {
if (isPlainObject(value) && value.format === RECORDING_EXPORT_FORMAT) {
const recording = normalizeRecording(value.recording);
return recording ? [recording] : [];
}
if (isPlainObject(value) && value.format === RECORDINGS_EXPORT_FORMAT && Array.isArray(value.recordings)) {
return value.recordings
.map(normalizeRecording)
.filter((recording): recording is TrainRecording => recording !== null);
}
if (Array.isArray(value)) {
return value
.map(normalizeRecording)
.filter((recording): recording is TrainRecording => recording !== null);
}
const recording = normalizeRecording(value);
return recording ? [recording] : [];
};
/** 録画インデックス(メタ一覧)を読み込む */
export const loadRecordingList = async (): Promise<RecordingMeta[]> => {
const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDINGS_INDEX).catch(() => null);
@@ -45,16 +195,57 @@ export const loadRecordingList = async (): Promise<RecordingMeta[]> => {
/** フル録画データをIDで読み込む */
export const loadRecordingById = async (id: string): Promise<TrainRecording | null> => {
const raw = await AS.getItem((STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as any).catch(() => null);
return parse<TrainRecording>(raw);
const file = getRecordingFile(id);
if (file.exists) {
const raw = await file.text().catch(() => null);
const recording = parse<TrainRecording>(raw);
if (recording) return recording;
}
const legacyKey = getLegacyRecordingStorageKey(id);
const legacyRaw = await AS.getItem(legacyKey).catch(() => null);
const legacyRecording = parse<TrainRecording>(legacyRaw);
if (!legacyRecording) return null;
await writeRecordingFile(legacyRecording);
await removeLegacyRecordingStorage(id);
return legacyRecording;
};
/** 全録画を新しい順で読み込む */
export const loadAllRecordings = async (): Promise<TrainRecording[]> => {
const list = await loadRecordingList();
const recordings = await Promise.all(list.map((meta) => loadRecordingById(meta.id)));
return recordings.filter((recording): recording is TrainRecording => recording !== null);
};
const migrateIndexedLegacyRecordings = async (): Promise<boolean> => {
const list = await loadRecordingList();
if (list.length === 0) return true;
const results = await Promise.all(
list.map(async (meta) => {
const recording = await loadRecordingById(meta.id).catch(() => null);
return recording !== null;
}),
);
return results.every(Boolean);
};
const writeRecordingFile = async (recording: TrainRecording): Promise<void> => {
const file = getRecordingFile(recording.id, true);
if (file.exists) {
file.delete();
}
file.create({ overwrite: true });
file.write(JSON.stringify(recording));
};
/** 録画を保存してインデックスに追加する */
export const saveRecording = async (recording: TrainRecording): Promise<void> => {
await AS.setItem(
(STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + recording.id) as any,
JSON.stringify(recording),
);
await writeRecordingFile(recording);
await removeLegacyRecordingStorage(recording.id);
const list = await loadRecordingList();
const meta: RecordingMeta = {
id: recording.id,
@@ -69,7 +260,11 @@ export const saveRecording = async (recording: TrainRecording): Promise<void> =>
/** 録画をIDで削除する */
export const deleteRecordingById = async (id: string): Promise<void> => {
await AS.removeItem((STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as any).catch(() => {});
const file = getRecordingFile(id);
if (file.exists) {
file.delete();
}
await removeLegacyRecordingStorage(id);
const list = await loadRecordingList();
await AS.setItem(
STORAGE_KEYS.MOCK_RECORDINGS_INDEX,
@@ -78,17 +273,99 @@ export const deleteRecordingById = async (id: string): Promise<void> => {
};
/**
* MOCK_RECORDING
*
* MOCK_RECORDING/SQLite保存
*/
export const migrateOldRecording = async (): Promise<void> => {
const indexedRecordingsMigrated = await migrateIndexedLegacyRecordings();
const list = await loadRecordingList();
if (list.length > 0) return; // 既に移行済み
if (list.length > 0) {
if (indexedRecordingsMigrated) {
await cleanupLegacyRecordingStorage();
}
return;
}
const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => null);
const old = parse<Omit<TrainRecording, 'id'>>(raw);
if (!old) return;
const id = generateRecordingId(old.recordedAt);
await saveRecording({ ...old, id });
await AS.removeItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => {});
if (old) {
const id = generateRecordingId(old.recordedAt);
await saveRecording({ ...old, id });
await AS.removeItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => {});
}
await cleanupLegacyRecordingStorage();
};
/** 録画1件をファイル/クリップボード用JSON文字列へ変換する */
export const buildRecordingExportText = async (id: string): Promise<string> => {
const recording = await loadRecordingById(id);
if (!recording) {
throw new Error('録画データが見つかりませんでした。');
}
const payload: RecordingExportEnvelope = {
format: RECORDING_EXPORT_FORMAT,
version: 1,
exportedAt: new Date().toISOString(),
recording,
};
return JSON.stringify(payload, null, 2);
};
/** 全録画をファイル/クリップボード用JSON文字列へ変換する */
export const buildAllRecordingsExportText = async (): Promise<string> => {
const recordings = await loadAllRecordings();
if (recordings.length === 0) {
throw new Error('書き出せる録画がありません。');
}
const payload: RecordingsExportEnvelope = {
format: RECORDINGS_EXPORT_FORMAT,
version: 1,
exportedAt: new Date().toISOString(),
recordings,
};
return JSON.stringify(payload, null, 2);
};
/** コピペ/ファイルから読み込んだJSON文字列を録画として保存する */
export const importRecordingsFromText = async (text: string): Promise<RecordingImportResult> => {
const trimmed = text.trim();
if (!trimmed) {
throw new Error('インポートするJSONが空です。');
}
let parsedValue: unknown;
try {
parsedValue = JSON.parse(trimmed);
} catch {
throw new Error('JSONとして読み取れませんでした。ファイル内容を確認してください。');
}
const recordings = extractRecordingsFromImport(parsedValue);
if (recordings.length === 0) {
throw new Error('有効な録画データが見つかりませんでした。');
}
if (await migrateIndexedLegacyRecordings()) {
await cleanupLegacyRecordingStorage();
}
const existingIds = new Set((await loadRecordingList()).map((meta) => meta.id));
let overwrittenCount = 0;
for (const recording of recordings) {
if (existingIds.has(recording.id)) {
overwrittenCount += 1;
}
await saveRecording(recording);
existingIds.add(recording.id);
}
return {
importedCount: recordings.length,
overwrittenCount,
};
};
+16
View File
@@ -5,6 +5,22 @@ export const rootNavigationRef = createNavigationContainerRef<any>();
/** positions タブの Stack.Navigator navigation を登録するグローバルref */
export const positionsStackNavRef: { current: any } = { current: null };
export const positionsLifecycleRef: {
current: {
isUnstable: boolean;
blockTabExit: boolean;
resetBeforeLeave: (() => void) | null;
deactivateStack: (() => void) | null;
};
} = {
current: {
isUnstable: false,
blockTabExit: false,
resetBeforeLeave: null,
deactivateStack: null,
},
};
/**
* popToTop
* 使
+9 -1
View File
@@ -28,8 +28,9 @@ const forModalPresentationAndroid = (
export const optionData = {
gestureEnabled: true,
...TransitionPresets.ModalPresentationIOS,
...(Platform.OS === "ios" ? TransitionPresets.ModalPresentationIOS : {}),
...(Platform.OS === "android" && {
animationEnabled: false,
cardStyleInterpolator: forModalPresentationAndroid,
}),
cardOverlayEnabled: true,
@@ -37,3 +38,10 @@ export const optionData = {
headerShown: false,
detachPreviousScreen: false,
};
export const pushTransitionOptions =
Platform.OS === "ios"
? TransitionPresets.SlideFromRightIOS
: {
animationEnabled: false,
};
+73 -13
View File
@@ -2,6 +2,26 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, AppStateStatus } from "react-native";
import WebView from "react-native-webview";
type WebViewRemountReason =
| "manual"
| "app_background"
| "render_process_gone"
| "content_process_terminated"
| "loading_timeout"
| "pong_timeout"
| "blank_detected";
type WebViewRemountData = Record<string, string | number | boolean | null | undefined>;
type UseWebViewRemountOptions = {
pingEnabled?: boolean;
backgroundThresholdMs?: number;
isFocused?: boolean;
pauseWatchdogWhenUnfocused?: boolean;
ignoreProcessTerminationWhenUnfocused?: boolean;
onRemount?: (reason: WebViewRemountReason, data?: WebViewRemountData) => void;
};
/**
* WebView
*
@@ -13,22 +33,32 @@ import WebView from "react-native-webview";
* pingHandlers 使 onMessage spread
* ping pingEnabled: true
*/
export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundThresholdMs?: number }) {
export function useWebViewRemount(options?: UseWebViewRemountOptions) {
const pingEnabled = options?.pingEnabled ?? false;
const backgroundThresholdMs = options?.backgroundThresholdMs ?? 300_000; // デフォルト5分
const isFocused = options?.isFocused ?? true;
const pauseWatchdogWhenUnfocused = options?.pauseWatchdogWhenUnfocused ?? false;
const ignoreProcessTerminationWhenUnfocused = options?.ignoreProcessTerminationWhenUnfocused ?? false;
const onRemount = options?.onRemount;
const [remountKey, setRemountKey] = useState(0);
const backgroundedAt = useRef<number | null>(null);
const webViewRef = useRef<WebView>(null);
const ignoredProcessTerminationRef = useRef<WebViewRemountReason | null>(null);
// ping watchdog 用
const lastPongAt = useRef(Date.now());
const isLoadingRef = useRef(true);
const remount = useCallback(() => {
const triggerRemount = useCallback((reason: WebViewRemountReason, data?: WebViewRemountData) => {
onRemount?.(reason, data);
lastPongAt.current = Date.now();
isLoadingRef.current = true;
setRemountKey((k) => k + 1);
}, []);
}, [onRemount]);
const remount = useCallback(() => {
triggerRemount("manual");
}, [triggerRemount]);
// バックグラウンドから復帰したら再マウント(デフォルト5分超)
useEffect(() => {
@@ -39,13 +69,13 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundT
const elapsed = Date.now() - backgroundedAt.current;
backgroundedAt.current = null;
if (elapsed > backgroundThresholdMs) {
remount();
triggerRemount("app_background", { elapsedMs: elapsed });
}
}
};
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => subscription.remove();
}, [remount, backgroundThresholdMs]);
}, [backgroundThresholdMs, triggerRemount]);
// ping watchdog: 5秒ごとに生存確認と白画面検知を行う
// - ローディング中(isLoadingRef=true)でも45秒超なら remount(レンダラー死亡でonLoadEndが来ないケース)
@@ -55,29 +85,53 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundT
useEffect(() => {
if (!pingEnabled) return;
const id = setInterval(() => {
if (pauseWatchdogWhenUnfocused && !isFocused) {
lastPongAt.current = Date.now();
return;
}
const elapsed = Date.now() - lastPongAt.current;
if (isLoadingRef.current) {
// ローディング中でも45秒超はレンダラー死亡と判定
if (elapsed > 45_000) remount();
if (elapsed > 45_000) triggerRemount("loading_timeout", { elapsedMs: elapsed });
return;
}
// ロード完了後30秒 pong 無応答 → レンダラー死亡
if (elapsed > 30_000) {
remount();
triggerRemount("pong_timeout", { elapsedMs: elapsed });
return;
}
webViewRef.current?.injectJavaScript(
`(function(){var t=document.body?(document.body.innerText||'').replace(/\\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
`(function(){var t=document.body?(document.body.innerText||'').replace(/\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
);
}, 5_000);
return () => clearInterval(id);
}, [pingEnabled, remount]);
}, [isFocused, pauseWatchdogWhenUnfocused, pingEnabled, triggerRemount]);
const processHandlers = {
onRenderProcessGone: () => remount(),
onContentProcessDidTerminate: () => remount(),
onRenderProcessGone: (event?: any) => {
if (ignoreProcessTerminationWhenUnfocused && !isFocused) {
ignoredProcessTerminationRef.current = "render_process_gone";
lastPongAt.current = Date.now();
return;
}
triggerRemount("render_process_gone", { didCrash: event?.nativeEvent?.didCrash ?? null });
},
onContentProcessDidTerminate: () => {
if (ignoreProcessTerminationWhenUnfocused && !isFocused) {
ignoredProcessTerminationRef.current = "content_process_terminated";
lastPongAt.current = Date.now();
return;
}
triggerRemount("content_process_terminated");
},
} as const;
useEffect(() => {
if (!isFocused || !ignoredProcessTerminationRef.current) return;
ignoredProcessTerminationRef.current = null;
lastPongAt.current = Date.now();
}, [isFocused]);
// pingHandlers は onLoadEnd と onMessage を提供する
// 既存の onMessage がある場合は手動でマージすること
const pingHandlers = pingEnabled ? {
@@ -90,7 +144,7 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundT
setTimeout(() => {
if (!isLoadingRef.current) {
webViewRef.current?.injectJavaScript(
`(function(){var t=document.body?(document.body.innerText||'').replace(/\\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
`(function(){var t=document.body?(document.body.innerText||'').replace(/\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
);
}
}, 3000);
@@ -112,9 +166,15 @@ export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundT
if (maxTextLenRef.current > 20 && len < 5) {
blankCountRef.current += 1;
if (blankCountRef.current >= 3) {
const blankCount = blankCountRef.current;
const maxTextLength = maxTextLenRef.current;
blankCountRef.current = 0;
maxTextLenRef.current = 0;
remount();
triggerRemount("blank_detected", {
blankCount,
textLength: len,
maxTextLength,
});
}
} else {
blankCountRef.current = 0;
+156 -82
View File
@@ -29,6 +29,7 @@ import { lineList_LineWebID } from "@/lib/getStationList";
import { StationProps } from "@/lib/CommonTypes";
import { LocationObject } from "expo-location";
import { StationSource } from "@/types";
import * as Sentry from "@sentry/react-native";
configureReanimatedLogger({
level: ReanimatedLogLevel.error, // Set the log level to error
strict: true, // Reanimated runs in strict mode by default
@@ -54,12 +55,73 @@ export const Menu: FC<props> = (props) => {
const { favoriteStation } = useFavoriteStation();
const { originalStationList, getStationDataFromNameBase } = useStationList();
const [stationSource, _setStationSource] = useState<StationSource>({ type: "position" });
const [isHeavyContentReady, setIsHeavyContentReady] = useState(Platform.OS !== "android");
// 時刻表βの遅延レンダリング(画面遷移アニメーション完了後に描画)
const [ledReady, setLedReady] = useState(false);
const [ledReady, setLedReady] = useState(Platform.OS !== "android");
useEffect(() => {
const handle = InteractionManager.runAfterInteractions(() => setLedReady(true));
return () => handle.cancel();
}, []);
if (Platform.OS !== "android") {
setIsHeavyContentReady(true);
setLedReady(true);
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
if (!isMenuFocused) {
if (!isHeavyContentReady) return;
Sentry.addBreadcrumb({
category: "topMenu.runtime",
level: "info",
message: "top menu heavy content preserved on blur",
data: { mapMode },
});
return () => {
cancelled = true;
};
}
if (isHeavyContentReady && ledReady) {
return () => {
cancelled = true;
};
}
Sentry.addBreadcrumb({
category: "topMenu.runtime",
level: "info",
message: "top menu heavy content activation scheduled",
data: { mapMode },
});
const handle = InteractionManager.runAfterInteractions(() => {
timer = setTimeout(() => {
if (cancelled) return;
Sentry.addBreadcrumb({
category: "topMenu.runtime",
level: "info",
message: "top menu heavy content activated",
data: { mapMode },
});
setIsHeavyContentReady(true);
setLedReady(true);
}, 60);
});
return () => {
cancelled = true;
handle.cancel();
if (timer) clearTimeout(timer);
};
}, [isHeavyContentReady, isMenuFocused, ledReady, mapMode]);
useEffect(() => {
Sentry.setContext("top_menu_runtime", {
focused: isMenuFocused,
mapMode,
heavyContentReady: isHeavyContentReady,
ledReady,
});
}, [isHeavyContentReady, isMenuFocused, ledReady, mapMode]);
// 検索モードを閉じたときに戻るソースを記憶
const prevNonSearchTypeRef = useRef<"position" | "favorite">("position");
@@ -258,7 +320,9 @@ export const Menu: FC<props> = (props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listUpStation]);
useEffect(() => {
if (!isHeavyContentReady) return;
if (originalStationList == undefined) return;
if (!mapsRef.current) return;
if (listUpStation.length == 0) return;
if (listUpStation[listIndex] == undefined) return;
const { lat, lng } = listUpStation[listIndex][0];
@@ -279,7 +343,7 @@ export const Menu: FC<props> = (props) => {
} else {
mapsRef.current.animateToRegion(mapRegion, 1000);
}
}, [listIndex, listUpStation]);
}, [isHeavyContentReady, listIndex, listUpStation]);
return (
<View
@@ -313,83 +377,93 @@ export const Menu: FC<props> = (props) => {
}
}}
>
<MapView
ref={mapsRef}
style={{ width: "100%", height: mapMode ? MapFullHeight : mapHeight }}
showsUserLocation={true}
loadingEnabled={true}
showsMyLocationButton={false}
moveOnMarkerPress={false}
showsCompass={false}
userInterfaceStyle={isDark ? "dark" : "light"}
//provider={PROVIDER_GOOGLE}
initialRegion={{
latitude: 33.774519,
longitude: 133.533306,
latitudeDelta: 1.8, //小さくなるほどズーム
longitudeDelta: 1.8,
}}
onTouchStart={() => {
LayoutAnimation.configureNext({
duration: 300,
create: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
update: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
});
setMapMode(true);
goToMap();
}}
>
{listUpStation.map(([{ lat, lng, StationNumber }], index) => (
<Marker
key={index + StationNumber}
coordinate={{
latitude: lat,
longitude: lng,
}}
anchor={{ x: 0.5, y: 1 }}
tracksViewChanges={false}
onPress={() => {
setMapMode(false);
setListIndex(index);
if (mapsRef.current) {
mapsRef.current.animateToRegion(
{
latitude: lat,
longitude: lng,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
{isHeavyContentReady ? (
<MapView
ref={mapsRef}
style={{ width: "100%", height: mapMode ? MapFullHeight : mapHeight }}
showsUserLocation={true}
loadingEnabled={true}
showsMyLocationButton={false}
moveOnMarkerPress={false}
showsCompass={false}
userInterfaceStyle={isDark ? "dark" : "light"}
//provider={PROVIDER_GOOGLE}
initialRegion={{
latitude: 33.774519,
longitude: 133.533306,
latitudeDelta: 1.8, //小さくなるほどズーム
longitudeDelta: 1.8,
}}
onTouchStart={() => {
LayoutAnimation.configureNext({
duration: 300,
create: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
update: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
});
setMapMode(true);
goToMap();
}}
>
{listUpStation.map(([{ lat, lng, StationNumber }], index) => (
<Marker
key={index + StationNumber}
coordinate={{
latitude: lat,
longitude: lng,
}}
anchor={{ x: 0.5, y: 1 }}
tracksViewChanges={false}
onPress={() => {
setMapMode(false);
setListIndex(index);
if (mapsRef.current) {
mapsRef.current.animateToRegion(
{
latitude: lat,
longitude: lng,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
},
1000
);
}
LayoutAnimation.configureNext({
duration: 300,
create: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
1000
);
}
LayoutAnimation.configureNext({
duration: 300,
create: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
update: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
});
returnToTop();
}}
>
<Image
source={require("@/assets/reccha-small.png")}
style={{ width: MAP_PIN_SIZE, height: MAP_PIN_SIZE }}
resizeMode="contain"
/>
</Marker>
))}
</MapView>
update: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
});
returnToTop();
}}
>
<Image
source={require("@/assets/reccha-small.png")}
style={{ width: MAP_PIN_SIZE, height: MAP_PIN_SIZE }}
resizeMode="contain"
/>
</Marker>
))}
</MapView>
) : (
<View
style={{
width: "100%",
height: mapMode ? MapFullHeight : mapHeight,
backgroundColor: colors.background,
}}
/>
)}
{!mapMode && (
<CarouselTypeChanger
{...{
@@ -405,7 +479,7 @@ export const Menu: FC<props> = (props) => {
/>
)}
{originalStationList.length != 0 && (
{originalStationList.length != 0 && isHeavyContentReady && (
<>
<CarouselBox
{...{
+5 -3
View File
@@ -1,7 +1,9 @@
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");
const {
getSentryExpoConfig
} = require("@sentry/react-native/metro");
const config = getDefaultConfig(__dirname);
const config = getSentryExpoConfig(__dirname);
// Ensure local Expo modules in modules/ are watched and resolved directly
config.watchFolders = [
@@ -22,4 +24,4 @@ config.transformer = {
experimentalImportSupport: true,
};
module.exports = config;
module.exports = config;
+711 -110
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -22,9 +22,15 @@
"react-native-responsive-screen",
"typescript",
"react",
"react-dom"
"react-dom",
"expo-live-activity",
"@react-native-vector-icons/fontawesome",
"@react-native-vector-icons/material-icons"
]
}
},
"autolinking": {
"nativeModulesDir": "./modules"
}
},
"dependencies": {
@@ -43,6 +49,7 @@
"@react-navigation/stack": "^7.8.6",
"@rneui/base": "5.0.0",
"@rneui/themed": "5.0.0",
"@sentry/react-native": "~7.11.0",
"dayjs": "^1.11.20",
"expo": "^55.0.8",
"expo-alternate-app-icons": "8.0.0",
@@ -51,9 +58,10 @@
"expo-build-properties": "~55.0.10",
"expo-clipboard": "~55.0.9",
"expo-constants": "~55.0.9",
"expo-dev-client": "~55.0.18",
"expo-dev-client": "~55.0.36",
"expo-device": "~55.0.10",
"expo-felica-reader": "file:./modules/expo-felica-reader",
"expo-document-picker": "~55.0.14",
"expo-file-system": "~55.0.23",
"expo-font": "~55.0.4",
"expo-haptics": "~55.0.9",
"expo-intent-launcher": "~55.0.9",
@@ -73,7 +81,7 @@
"lottie-react-native": "~7.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
"react-native": "0.83.6",
"react-native-actions-sheet": "10.1.2",
"react-native-android-widget": "^0.20.1",
"react-native-gesture-handler": "~2.30.0",
@@ -83,6 +91,7 @@
"react-native-responsive-screen": "^1.4.2",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.23.0",
"react-native-share": "^12.3.1",
"react-native-sortables": "1.9.4",
"react-native-storage": "^1.0.1",
"react-native-svg": "15.15.3",
@@ -91,23 +100,14 @@
"react-native-view-shot": "~4.0.3",
"react-native-web": "^0.21.2",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"typescript": "~5.9.3",
"expo-live-activity": "file:./modules/expo-live-activity"
"react-native-worklets": "0.7.4",
"typescript": "~5.9.3"
},
"devDependencies": {
"@types/react": "~19.2.14",
"babel-preset-expo": "~55.0.12",
"baseline-browser-mapping": "^2.10.9"
},
"resolutions": {
"react": "19.2.0",
"react-dom": "19.2.0",
"expo-asset": "55.0.10",
"expo-constants": "55.0.9",
"expo-manifests": "55.0.11",
"react-native-worklets": "0.7.2"
},
"private": true,
"name": "jrshikoku",
"version": "1.0.0"
+118
View File
@@ -0,0 +1,118 @@
{
"main": "index.js",
"scripts": {
"compile-web-script": "npx tsx scripts/compile-web-script.ts",
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"eject": "expo eject",
"pushWeb": "npx expo export -p web && netlify deploy --dir dist --prod",
"checkDiagram": "bash ./check.sh"
},
"expo": {
"doctor": {
"reactNativeDirectoryCheck": {
"exclude": [
"@rneui/base",
"@rneui/themed",
"@lottiefiles/dotlottie-react",
"expo-felica-reader",
"react-native-touchable-scale",
"react-native-vector-icons",
"react-native-responsive-screen",
"typescript",
"react",
"react-dom"
]
}
}
},
"dependencies": {
"@bacons/apple-targets": "^4.0.6",
"@expo/metro-runtime": "~55.0.6",
"@expo/ngrok": "^4.1.3",
"@expo/vector-icons": "^15.1.1",
"@gorhom/bottom-sheet": "^5",
"@lottiefiles/dotlottie-react": "^0.18.7",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-masked-view/masked-view": "0.3.2",
"@react-native-vector-icons/fontawesome": "^12.4.3",
"@react-native-vector-icons/material-icons": "^12.4.3",
"@react-navigation/bottom-tabs": "^7.15.6",
"@react-navigation/native": "^7.1.34",
"@react-navigation/stack": "^7.8.6",
"@rneui/base": "5.0.0",
"@rneui/themed": "5.0.0",
"@sentry/react-native": "^8.16.0",
"dayjs": "^1.11.20",
"expo": "^55.0.8",
"expo-alternate-app-icons": "8.0.0",
"expo-asset": "~55.0.10",
"expo-audio": "~55.0.9",
"expo-build-properties": "~55.0.10",
"expo-clipboard": "~55.0.9",
"expo-constants": "~55.0.9",
"expo-dev-client": "~55.0.18",
"expo-device": "~55.0.10",
"expo-document-picker": "~55.0.14",
"expo-felica-reader": "file:./modules/expo-felica-reader",
"expo-file-system": "~55.0.23",
"expo-font": "~55.0.4",
"expo-haptics": "~55.0.9",
"expo-intent-launcher": "~55.0.9",
"expo-keep-awake": "~55.0.4",
"expo-linear-gradient": "~55.0.9",
"expo-linking": "~55.0.8",
"expo-live-activity": "file:./modules/expo-live-activity",
"expo-localization": "~55.0.9",
"expo-location": "~55.1.4",
"expo-notifications": "~55.0.13",
"expo-screen-orientation": "~55.0.9",
"expo-sharing": "~55.0.14",
"expo-status-bar": "~55.0.4",
"expo-system-ui": "~55.0.10",
"expo-updates": "~55.0.15",
"expo-video": "~55.0.11",
"expo-web-browser": "~55.0.10",
"lottie-react-native": "~7.3.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
"react-native-actions-sheet": "10.1.2",
"react-native-android-widget": "^0.20.1",
"react-native-gesture-handler": "~2.30.0",
"react-native-maps": "1.27.2",
"react-native-reanimated": "4.2.1",
"react-native-reanimated-carousel": "^4.0.3",
"react-native-responsive-screen": "^1.4.2",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.23.0",
"react-native-share": "^12.3.1",
"react-native-sortables": "1.9.4",
"react-native-storage": "^1.0.1",
"react-native-svg": "15.15.3",
"react-native-touchable-scale": "^2.2.0",
"react-native-vector-icons": "^10.3.0",
"react-native-view-shot": "~4.0.3",
"react-native-web": "^0.21.2",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"typescript": "~5.9.3"
},
"devDependencies": {
"@types/react": "~19.2.14",
"babel-preset-expo": "~55.0.12",
"baseline-browser-mapping": "^2.10.9"
},
"resolutions": {
"react": "19.2.0",
"react-dom": "19.2.0",
"expo-asset": "55.0.10",
"expo-constants": "55.0.9",
"expo-manifests": "55.0.11",
"react-native-worklets": "0.7.2"
},
"private": true,
"name": "jrshikoku",
"version": "1.0.0"
}
+112 -7
View File
@@ -7,6 +7,10 @@ import React, {
useCallback,
FC,
} from "react";
import { Platform } from "react-native";
import * as DocumentPicker from "expo-document-picker";
import { File, Paths } from "expo-file-system";
import Share from "react-native-share";
import { ASCore } from "../storageControl";
import { AS } from "../storageControl";
@@ -26,12 +30,16 @@ import {
import {
TrainRecording,
RecordingMeta,
RecordingImportResult,
saveRecording,
loadRecordingList,
loadRecordingById,
deleteRecordingById,
migrateOldRecording,
generateRecordingId,
buildRecordingExportText as buildRecordingExportTextCore,
buildAllRecordingsExportText as buildAllRecordingsExportTextCore,
importRecordingsFromText as importRecordingsFromTextCore,
} from "../lib/mockApi/trainRecorder";
import { useNotification } from "../stateBox/useNotifications";
@@ -111,6 +119,11 @@ const initialState = {
seekToSnapshot: (_index: number) => {},
addTrainSnapshot: (_trains: TrainEntry[]) => {},
deleteRecording: (_id: string): Promise<void> => Promise.resolve(),
exportRecordingFile: (_id: string): Promise<void> => Promise.resolve(),
exportAllRecordingsFile: (): Promise<void> => Promise.resolve(),
importRecordingFile: (): Promise<RecordingImportResult | null> => Promise.resolve(null),
importRecordingsFromText: (_content: string): Promise<RecordingImportResult> =>
Promise.resolve({ importedCount: 0, overwrittenCount: 0 }),
};
const TrainMenuContext = createContext(initialState);
@@ -136,10 +149,10 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const [LoadError, setLoadError] = useState(false);
// バックエンドAPIベースURL(環境設定から読み込み)
const [backendApiBaseUrl, setBackendApiBaseUrl] = useState(
const [backendApiBaseUrl, setBackendApiBaseUrl] = useState<string>(
BACKEND_API_BASE_URLS.production,
);
const [diagramTodayUrl, setDiagramTodayUrl] = useState(
const [diagramTodayUrl, setDiagramTodayUrl] = useState<string>(
"https://jr-shikoku-api-data-storage.haruk.in/tmp/diagram-today.json",
);
@@ -220,9 +233,14 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const recordingStartTimeRef = useRef<number>(0);
const recordingSnapshotsRef = useRef<Array<{ t: number; trains: TrainEntry[] }>>([]);
const refreshRecordingList = async () => {
const list = await loadRecordingList();
setRecordingList(list);
};
// 起動時: 旧フォーマット移行 → 一覧読み込み
useEffect(() => {
migrateOldRecording().then(() => loadRecordingList()).then(setRecordingList).catch(() => {});
migrateOldRecording().then(refreshRecordingList).catch(() => {});
}, []);
// 再生ループ: playbackIndex / recorderState / playbackPaused が変わるたびに次へ進める
@@ -260,8 +278,7 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
snapshots: snaps,
};
await saveRecording(recording);
const list = await loadRecordingList();
setRecordingList(list);
await refreshRecordingList();
}
setRecorderState('idle');
};
@@ -306,8 +323,92 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
const deleteRecording = async (id: string) => {
await deleteRecordingById(id);
const list = await loadRecordingList();
setRecordingList(list);
await refreshRecordingList();
};
const downloadTextFileOnWeb = (fileName: string, content: string) => {
const web = globalThis as any;
if (!web.document || !web.URL || !web.Blob) {
throw new Error('この環境ではファイルのダウンロードに対応していません。');
}
const blob = new web.Blob([content], { type: 'application/json' });
const url = web.URL.createObjectURL(blob);
const anchor = web.document.createElement('a');
anchor.href = url;
anchor.download = fileName;
web.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
web.setTimeout(() => web.URL.revokeObjectURL(url), 0);
};
const shareJsonFile = async (fileName: string, content: string) => {
if (Platform.OS === 'web') {
downloadTextFileOnWeb(fileName, content);
return;
}
const file = new File(Paths.cache, fileName);
if (file.exists) {
file.delete();
}
file.create({ overwrite: true });
file.write(content);
await Share.open({
title: fileName,
subject: fileName,
url: file.uri,
type: 'application/json',
filename: fileName.replace(/\.json$/i, ''),
failOnCancel: false,
saveToFiles: true,
useInternalStorage: true,
});
};
const exportRecordingFile = async (id: string) => {
const content = await buildRecordingExportTextCore(id);
await shareJsonFile(`jrshikoku-recording-${id}.json`, content);
};
const exportAllRecordingsFile = async () => {
const content = await buildAllRecordingsExportTextCore();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
await shareJsonFile(`jrshikoku-recordings-${timestamp}.json`, content);
};
const readPickedRecordingText = async (asset: DocumentPicker.DocumentPickerAsset) => {
const webFile = (asset as any).file;
if (Platform.OS === 'web' && webFile && typeof webFile.text === 'function') {
return webFile.text();
}
const file = new File(asset.uri);
return file.text();
};
const importRecordingFile = async (): Promise<RecordingImportResult | null> => {
const result = await DocumentPicker.getDocumentAsync({
type: ['application/json', 'text/json', 'text/plain', '*/*'],
copyToCacheDirectory: true,
multiple: false,
base64: false,
});
if (result.canceled || !result.assets?.[0]) return null;
const content = await readPickedRecordingText(result.assets[0]);
const importResult = await importRecordingsFromTextCore(content);
await refreshRecordingList();
return importResult;
};
const importRecordingsFromText = async (content: string): Promise<RecordingImportResult> => {
const importResult = await importRecordingsFromTextCore(content);
await refreshRecordingList();
return importResult;
};
/** PosNum + Line → Pos テキスト(位置マスターから) */
@@ -472,6 +573,10 @@ export const TrainMenuProvider: FC<props> = ({ children }) => {
seekToSnapshot,
addTrainSnapshot,
deleteRecording,
exportRecordingFile,
exportAllRecordingsFile,
importRecordingFile,
importRecordingsFromText,
}}
>
{children}
+1489 -1605
View File
File diff suppressed because it is too large Load Diff