diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 65515e8..9e36ae9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -112,6 +112,11 @@ jobs: echo "Prebuilding iOS project..." npx expo prebuild --platform ios --clean --no-install + - name: Set iOS deployment target for pods + if: matrix.language == 'swift' + run: | + node -e "const fs=require('fs'); const p='ios/Podfile.properties.json'; const d=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,'utf8')):{}; d['ios.deploymentTarget']='15.5'; fs.writeFileSync(p, JSON.stringify(d, null, 2) + '\n'); console.log('Configured ios.deploymentTarget=15.5 in ' + p);" + - name: Install iOS dependencies if: matrix.language == 'swift' run: | @@ -166,14 +171,25 @@ jobs: run: | echo "Attempting fallback iOS build with minimal configuration..." cd ios - - # Try a minimal build using the same approach as main.yml - xcodebuild -workspace pulse.xcworkspace \ - -scheme pulse \ - -configuration Debug \ - -sdk iphonesimulator \ - -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.2' \ - build-for-testing + + # Guard against missing workspace when pod install failed earlier. + if [ ! -d "pulse.xcworkspace" ]; then + echo "pulse.xcworkspace is missing; retrying pod install before fallback build..." + pod install --repo-update + fi + + if [ ! -d "pulse.xcworkspace" ]; then + echo "❌ pulse.xcworkspace still missing after pod install. Fallback build cannot continue." + exit 1 + fi + + # Try a minimal build using the same approach as main.yml. + xcodebuild -workspace pulse.xcworkspace \ + -scheme pulse \ + -configuration Debug \ + -sdk iphonesimulator \ + -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.2' \ + build-for-testing - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0560393..482e705 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -180,6 +180,10 @@ jobs: echo "Prebuilding iOS project..." npx expo prebuild --platform ios --clean + - name: Set iOS deployment target for pods + run: | + node -e "const fs=require('fs'); const p='ios/Podfile.properties.json'; const d=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,'utf8')):{}; d['ios.deploymentTarget']='15.5'; fs.writeFileSync(p, JSON.stringify(d, null, 2) + '\n'); console.log('Configured ios.deploymentTarget=15.5 in ' + p);" + - name: Install iOS dependencies run: | cd ios diff --git a/.github/workflows/testflight-deploy.yml b/.github/workflows/testflight-deploy.yml index a030d86..cfc3230 100644 --- a/.github/workflows/testflight-deploy.yml +++ b/.github/workflows/testflight-deploy.yml @@ -49,6 +49,10 @@ jobs: - name: Prebuild iOS project run: npx expo prebuild --clean + - name: Set iOS deployment target for pods + run: | + node -e "const fs=require('fs'); const p='ios/Podfile.properties.json'; const d=fs.existsSync(p)?JSON.parse(fs.readFileSync(p,'utf8')):{}; d['ios.deploymentTarget']='15.5'; fs.writeFileSync(p, JSON.stringify(d, null, 2) + '\n'); console.log('Configured ios.deploymentTarget=15.5 in ' + p);" + - name: Install iOS dependencies run: | cd ios diff --git a/app.json b/app.json index 5fb3a93..a7ebbfe 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "pulse", "slug": "pulse", - "version": "1.1.2", + "version": "1.1.3", "orientation": "portrait", "icon": "./assets/images/pulse-logo.png", "scheme": "pulsecam", @@ -76,4 +76,4 @@ }, "owner": "morepriyam" } -} \ No newline at end of file +} diff --git a/app/_layout.tsx b/app/_layout.tsx index d692309..b09d2cf 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,12 +8,18 @@ import { useRouter } from "expo-router"; import { Stack } from "expo-router"; import * as Linking from "expo-linking"; import { StatusBar } from "expo-status-bar"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import "react-native-reanimated"; import { PermissionMonitor } from "@/components/PermissionMonitor"; +import { ReportIssueFab } from "@/components/ReportIssueFab"; +import { ReportIssueModal } from "@/components/ReportIssueModal"; import { useColorScheme } from "@/hooks/useColorScheme"; +import { + BUG_REPORT_NOTIFICATION_OPEN_ACTION, + openBugReportNotificationUrl, +} from "@/utils/localNotification"; import { storeUploadConfigForDraft } from "@/utils/uploadConfig"; import { addDestination } from "@/utils/uploadDestinations"; @@ -23,6 +29,7 @@ const isUUIDv4 = (uuid: string) => export default function RootLayout() { const colorScheme = useColorScheme(); const router = useRouter(); + const [isReportIssueModalVisible, setIsReportIssueModalVisible] = useState(false); const [loaded] = useFonts({ "Roboto-Regular": require("../assets/fonts/Roboto-Regular.ttf"), "Roboto-Bold": require("../assets/fonts/Roboto-Bold.ttf"), @@ -112,6 +119,56 @@ export default function RootLayout() { }; }, [router]); + useEffect(() => { + let cancelled = false; + let subscription: { remove: () => void } | null = null; + + const setupNotifications = async () => { + const Notifications = await import("expo-notifications"); + + const handleResponse = async (response: { + actionIdentifier: string; + notification: { + request: { + content: { + data?: { + issueUrl?: string | null; + }; + }; + }; + }; + }) => { + if (cancelled) return; + + const issueUrl = response.notification.request.content.data?.issueUrl; + if (!issueUrl) return; + + if ( + response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER || + response.actionIdentifier === BUG_REPORT_NOTIFICATION_OPEN_ACTION + ) { + await openBugReportNotificationUrl(issueUrl); + } + }; + + subscription = Notifications.addNotificationResponseReceivedListener((response) => { + void handleResponse(response); + }); + + const lastResponse = await Notifications.getLastNotificationResponseAsync(); + if (lastResponse) { + await handleResponse(lastResponse as never); + } + }; + + void setupNotifications(); + + return () => { + cancelled = true; + subscription?.remove(); + }; + }, []); + if (!loaded) { // Async font loading only occurs in development. return null; @@ -184,6 +241,11 @@ export default function RootLayout() { /> + setIsReportIssueModalVisible(true)} /> + setIsReportIssueModalVisible(false)} + /> diff --git a/components/ReportIssueFab.tsx b/components/ReportIssueFab.tsx new file mode 100644 index 0000000..23222e4 --- /dev/null +++ b/components/ReportIssueFab.tsx @@ -0,0 +1,59 @@ +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; +import { usePathname } from "expo-router"; +import * as React from "react"; +import { Pressable, PressableStateCallbackType, StyleSheet } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +interface ReportIssueFabProps { + onPress: () => void; +} + +export function ReportIssueFab({ onPress }: ReportIssueFabProps) { + const pathname = usePathname(); + const insets = useSafeAreaInsets(); + const keepLowerOffset = + pathname.includes("shorts") || pathname.includes("trim-segment"); + const topOffset = keepLowerOffset + ? insets.top + 104 + : insets.top + 15; + + return ( + [ + styles.button, + { + top: topOffset, + opacity: pressed ? 0.85 : 1, + transform: [{ scale: pressed ? 0.97 : 1 }], + }, + ]} + > + + + ); +} + +const styles = StyleSheet.create({ + button: { + position: "absolute", + right: 25, + width: 40, + height: 40, + borderRadius: 25, + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(0, 0, 0, 0.78)", + borderWidth: 1.5, + borderColor: "rgba(255, 255, 255, 0.65)", + zIndex: 950, + elevation: 10, + shadowColor: "#000", + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.4, + shadowRadius: 5, + }, +}); diff --git a/components/ReportIssueModal.tsx b/components/ReportIssueModal.tsx new file mode 100644 index 0000000..bd58bb7 --- /dev/null +++ b/components/ReportIssueModal.tsx @@ -0,0 +1,529 @@ +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; +import * as React from "react"; +import { + ActivityIndicator, + GestureResponderEvent, + Linking, + Modal, + Pressable, + StyleSheet, + TextInput, + View, +} from "react-native"; + +import { ThemedText } from "@/components/ThemedText"; +import { Colors } from "@/constants/Colors"; +import { useColorScheme } from "@/hooks/useColorScheme"; +import { + SubmitIssueReportProgress, + SubmitIssueReportResult, + submitIssueReport, +} from "@/utils/reportIssue"; +import { + notifyBackgroundBugReportResult, +} from "@/utils/localNotification"; + +interface ReportIssueModalProps { + visible: boolean; + onClose: () => void; +} + +export function ReportIssueModal({ visible, onClose }: ReportIssueModalProps) { + const colorScheme = useColorScheme(); + const colors = colorScheme === "dark" ? Colors.dark : Colors.light; + + const [summary, setSummary] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [includeDraftFolder, setIncludeDraftFolder] = React.useState(false); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(null); + const [submittedIssue, setSubmittedIssue] = React.useState(null); + const [submitProgress, setSubmitProgress] = React.useState(0); + const [submitProgressMessage, setSubmitProgressMessage] = React.useState(""); + const [showBackgroundUploadButton, setShowBackgroundUploadButton] = React.useState(false); + const [activeSubmissionToken, setActiveSubmissionToken] = React.useState(null); + const backgroundEnabledByTokenRef = React.useRef>(new Map()); + + const normalizedSummary = summary.trim(); + const normalizedDescription = description.trim(); + + const canUploadInBackground = showBackgroundUploadButton && isSubmitting; + const isBusy = isSubmitting; + + const canSubmit = React.useMemo( + () => !!normalizedSummary && !!normalizedDescription && !isSubmitting, + [isSubmitting, normalizedDescription, normalizedSummary] + ); + + const resetAndClose = React.useCallback(() => { + setSummary(""); + setDescription(""); + setIncludeDraftFolder(false); + setErrorMessage(null); + setSubmittedIssue(null); + setSubmitProgress(0); + setSubmitProgressMessage(""); + setShowBackgroundUploadButton(false); + setActiveSubmissionToken(null); + onClose(); + }, [onClose]); + + const handleSubmitProgress = React.useCallback( + (progressUpdate: SubmitIssueReportProgress) => { + setSubmitProgress(progressUpdate.progress); + if (progressUpdate.message) { + setSubmitProgressMessage(progressUpdate.message); + return; + } + + if (progressUpdate.phase === "uploading") { + const pct = Math.round(progressUpdate.progress * 100); + setSubmitProgressMessage(`Uploading report... ${pct}%`); + } else if (progressUpdate.phase === "finalizing") { + setSubmitProgressMessage("Finalizing issue report..."); + } else { + setSubmitProgressMessage("Preparing payload..."); + } + }, + [] + ); + + const handleOpenIssueUrl = async () => { + if (!submittedIssue?.issueUrl) return; + try { + const supported = await Linking.canOpenURL(submittedIssue.issueUrl); + if (!supported) { + setErrorMessage("Unable to open issue URL on this device."); + return; + } + await Linking.openURL(submittedIssue.issueUrl); + } catch { + setErrorMessage("Unable to open issue URL on this device."); + } + }; + + const handleSubmit = async () => { + if (!canSubmit) { + setErrorMessage("Summary and description are required."); + return; + } + + setIsSubmitting(true); + setErrorMessage(null); + setSubmitProgress(0); + setSubmitProgressMessage("Preparing payload..."); + + const currentSubmissionToken = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; + backgroundEnabledByTokenRef.current.set(currentSubmissionToken, false); + setActiveSubmissionToken(currentSubmissionToken); + setShowBackgroundUploadButton(includeDraftFolder); + + try { + const result = await submitIssueReport( + { + summary, + description, + includeDraftFolder, + }, + handleSubmitProgress + ); + + const shouldNotifyInBackground = + backgroundEnabledByTokenRef.current.get(currentSubmissionToken) === true; + + if (shouldNotifyInBackground) { + void notifyBackgroundBugReportResult(true, result.issueUrl); + } else { + setSubmittedIssue(result); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to submit issue report."; + console.error(error); + + const shouldNotifyInBackground = + backgroundEnabledByTokenRef.current.get(currentSubmissionToken) === true; + + if (shouldNotifyInBackground) { + void notifyBackgroundBugReportResult(false); + } else { + setErrorMessage(message); + } + } finally { + backgroundEnabledByTokenRef.current.delete(currentSubmissionToken); + setShowBackgroundUploadButton(false); + setActiveSubmissionToken(null); + setIsSubmitting(false); + } + }; + + const handleUploadInBackground = React.useCallback(() => { + if (!canUploadInBackground || !activeSubmissionToken) { + return; + } + + backgroundEnabledByTokenRef.current.set(activeSubmissionToken, true); + resetAndClose(); + }, [activeSubmissionToken, canUploadInBackground, resetAndClose]); + + return ( + { + if (isBusy) return; + resetAndClose(); + }} + > + { + if (isBusy) return; + resetAndClose(); + }} + > + event.stopPropagation()} + > + + + Report an Issue + + + + + + + {submittedIssue ? ( + + + {`Issue created successfully. Thank you for the feedback. We will work on it.`} + + + + Open GitHub URL + + + + Close + + + ) : ( + <> + Summary + + + Description + + + setIncludeDraftFolder((prev: boolean) => !prev)} + style={styles.checkboxRow} + > + + {includeDraftFolder ? ( + + ) : null} + + Include draft folder + + + {includeDraftFolder ? ( + + Note: This draft folder will become publicly accessible on GitHub once you upload it. + + ) : null} + + {isSubmitting ? ( + + + + + + {submitProgressMessage || "Uploading report..."} + + + ) : null} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + {showBackgroundUploadButton ? ( + + Upload in Background + + ) : null} + + + {isSubmitting ? ( + + + + {`${Math.round(submitProgress * 100)}%`} + + + ) : ( + Submit + )} + + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.55)", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 16, + }, + modalCard: { + width: "100%", + borderRadius: 14, + borderWidth: 1, + padding: 16, + maxWidth: 460, + }, + headerRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 8, + }, + title: { + fontSize: 20, + }, + closeButton: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: "center", + justifyContent: "center", + }, + label: { + marginTop: 10, + marginBottom: 6, + fontFamily: "Roboto-Bold", + }, + input: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontFamily: "Roboto-Regular", + fontSize: 15, + }, + descriptionInput: { + minHeight: 110, + }, + checkboxRow: { + flexDirection: "row", + alignItems: "center", + marginTop: 14, + }, + checkbox: { + width: 22, + height: 22, + borderRadius: 6, + borderWidth: 1, + alignItems: "center", + justifyContent: "center", + }, + checkboxLabel: { + marginLeft: 10, + fontSize: 14, + }, + noteText: { + marginTop: 6, + fontSize: 12, + lineHeight: 17, + fontFamily: "Roboto-Regular", + }, + progressContainer: { + marginTop: 12, + }, + progressTrack: { + height: 8, + borderRadius: 999, + overflow: "hidden", + }, + progressFill: { + height: "100%", + borderRadius: 999, + }, + progressText: { + marginTop: 6, + fontSize: 13, + fontFamily: "Roboto-Regular", + }, + errorText: { + marginTop: 12, + fontFamily: "Roboto-Regular", + }, + successText: { + marginTop: 10, + marginBottom: 16, + fontFamily: "Roboto-Regular", + fontSize: 15, + lineHeight: 22, + }, + secondaryButton: { + borderWidth: 1, + height: 44, + borderRadius: 10, + alignItems: "center", + justifyContent: "center", + marginBottom: 10, + }, + secondaryButtonText: { + fontFamily: "Roboto-Bold", + fontSize: 15, + }, + footerButtons: { + marginTop: 14, + flexDirection: "row", + gap: 10, + }, + secondaryFooterButton: { + flex: 1, + borderWidth: 1, + height: 46, + borderRadius: 10, + alignItems: "center", + justifyContent: "center", + }, + footerButtonHalf: { + flex: 1, + marginTop: 0, + }, + submitButton: { + marginTop: 14, + height: 46, + borderRadius: 10, + alignItems: "center", + justifyContent: "center", + }, + submitButtonText: { + color: "#FFFFFF", + fontFamily: "Roboto-Bold", + fontSize: 16, + }, + submitProgressInline: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, +}); diff --git a/components/TimeSelectorButton.tsx b/components/TimeSelectorButton.tsx index 2b55a12..18f7882 100644 --- a/components/TimeSelectorButton.tsx +++ b/components/TimeSelectorButton.tsx @@ -44,6 +44,9 @@ export default function TimeSelectorButton({ <> setIsModalVisible(true)} > @@ -90,12 +93,19 @@ export default function TimeSelectorButton({ const styles = StyleSheet.create({ selectorButton: { - backgroundColor: "rgba(0, 0, 0, 0.6)", + backgroundColor: "rgba(0, 0, 0, 0.78)", width: 40, height: 40, borderRadius: 25, + borderWidth: 1.5, + borderColor: "rgba(255, 255, 255, 0.65)", justifyContent: "center", alignItems: "center", + elevation: 10, + shadowColor: "#000", + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: 0.4, + shadowRadius: 5, }, selectorText: { color: "#ffffff", diff --git a/ios/Podfile.properties.json b/ios/Podfile.properties.json index 417e2e5..37edce2 100644 --- a/ios/Podfile.properties.json +++ b/ios/Podfile.properties.json @@ -1,5 +1,6 @@ { "expo.jsEngine": "hermes", "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", - "newArchEnabled": "true" + "newArchEnabled": "true", + "ios.deploymentTarget": "15.5" } diff --git a/package-lock.json b/package-lock.json index f478dcc..ade88f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "expo-image": "~2.4.0", "expo-image-picker": "~16.1.4", "expo-linking": "~7.1.7", + "expo-notifications": "~0.31.5", "expo-router": "~5.1.7", "expo-sharing": "~13.1.5", "expo-splash-screen": "~0.30.10", @@ -45,7 +46,9 @@ "react-native-video": "^6.18.0", "react-native-video-trimmer-ui": "github:adithya1012/react-native-video-trimmer-ui", "react-native-web": "~0.20.0", - "react-native-webview": "13.13.5" + "react-native-webview": "13.13.5", + "react-native-zip-archive": "^7.0.2", + "tus-js-client": "^4.3.1" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -2342,6 +2345,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ide/backoff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", + "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4203,6 +4212,19 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4223,7 +4245,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -4429,6 +4450,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4630,7 +4657,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -4649,7 +4675,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4663,7 +4688,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4946,6 +4970,15 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/combine-errors": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.3.tgz", + "integrity": "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==", + "dependencies": { + "custom-error-instance": "2.1.1", + "lodash.uniqby": "4.5.0" + } + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -5171,6 +5204,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/custom-error-instance": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/custom-error-instance/-/custom-error-instance-2.1.1.tgz", + "integrity": "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==", + "license": "ISC" + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5292,7 +5331,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -5319,7 +5357,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -5408,7 +5445,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5552,7 +5588,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5562,7 +5597,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5600,7 +5634,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6166,6 +6199,15 @@ } } }, + "node_modules/expo-application": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-6.1.5.tgz", + "integrity": "sha512-ToImFmzw8luY043pWFJhh2ZMm4IwxXoHXxNoGdlhD4Ym6+CCmkAvCglg0FK8dMLzAb+/XabmOE7Rbm8KZb6NZg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-asset": { "version": "11.1.7", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-11.1.7.tgz", @@ -6368,6 +6410,26 @@ "invariant": "^2.2.4" } }, + "node_modules/expo-notifications": { + "version": "0.31.5", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.31.5.tgz", + "integrity": "sha512-HsitfTrSESFDWwaX0Y+6GQlWEooQqZKdGbNTwTPHfp5PNCr02tVPwwya9j1tdg3Awj8/vmfXmSxzNhULfmgJhQ==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.7.6", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~6.1.5", + "expo-constants": "~17.1.8" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-router": { "version": "5.1.10", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-5.1.10.tgz", @@ -6726,7 +6788,6 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -6836,7 +6897,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6864,7 +6924,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6898,7 +6957,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -7039,7 +7097,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7080,7 +7137,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -7109,7 +7165,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7122,7 +7177,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7378,6 +7432,22 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7482,7 +7552,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7604,7 +7673,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.4", @@ -7646,6 +7714,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", @@ -7698,7 +7782,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7742,6 +7825,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -7781,7 +7876,6 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -8110,6 +8204,12 @@ "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", "license": "MIT" }, + "node_modules/js-base64": { + "version": "3.7.8", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", + "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8526,6 +8626,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash._baseiteratee": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz", + "integrity": "sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==", + "license": "MIT", + "dependencies": { + "lodash._stringtopath": "~4.8.0" + } + }, + "node_modules/lodash._basetostring": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz", + "integrity": "sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==", + "license": "MIT" + }, + "node_modules/lodash._baseuniq": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz", + "integrity": "sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==", + "license": "MIT", + "dependencies": { + "lodash._createset": "~4.0.0", + "lodash._root": "~3.0.0" + } + }, + "node_modules/lodash._createset": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/lodash._createset/-/lodash._createset-4.0.3.tgz", + "integrity": "sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==", + "license": "MIT" + }, + "node_modules/lodash._root": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz", + "integrity": "sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==", + "license": "MIT" + }, + "node_modules/lodash._stringtopath": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/lodash._stringtopath/-/lodash._stringtopath-4.8.0.tgz", + "integrity": "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==", + "license": "MIT", + "dependencies": { + "lodash._basetostring": "~4.12.0" + } + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -8545,6 +8691,16 @@ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "license": "MIT" }, + "node_modules/lodash.uniqby": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.5.0.tgz", + "integrity": "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==", + "license": "MIT", + "dependencies": { + "lodash._baseiteratee": "~4.7.0", + "lodash._baseuniq": "~4.6.0" + } + }, "node_modules/log-symbols": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", @@ -8668,7 +8824,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9344,11 +9499,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9358,7 +9528,6 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9848,7 +10017,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10001,6 +10169,23 @@ "dev": true, "license": "MIT" }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -10036,6 +10221,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -10408,6 +10599,16 @@ "react-native": "*" } }, + "node_modules/react-native-zip-archive": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/react-native-zip-archive/-/react-native-zip-archive-7.0.2.tgz", + "integrity": "sha512-msCRJMcwH6NVZ2/zoC+1nvA0wlpYRnMxteQywS9nt4BzXn48tZpaVtE519QEZn0xe3ygvgsWx5cdPoE9Jx3bsg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.6", + "react-native": ">=0.60.0" + } + }, "node_modules/react-native/node_modules/@react-native/virtualized-lists": { "version": "0.79.6", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.79.6.tgz", @@ -10634,6 +10835,12 @@ "path-parse": "^1.0.5" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -10707,6 +10914,15 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -10805,7 +11021,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -10952,7 +11167,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -11889,6 +12103,24 @@ "license": "0BSD", "optional": true }, + "node_modules/tus-js-client": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-4.3.1.tgz", + "integrity": "sha512-ZLeYmjrkaU1fUsKbIi8JML52uAocjEZtBx4DKjRrqzrZa0O4MYwT6db+oqePlspV+FxXJAyFBc/L5gwUi2OFsg==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.1.2", + "combine-errors": "^3.0.3", + "is-stream": "^2.0.0", + "js-base64": "^3.7.2", + "lodash.throttle": "^4.1.1", + "proper-lockfile": "^4.1.2", + "url-parse": "^1.5.7" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -12208,6 +12440,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-latest-callback": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", @@ -12226,6 +12468,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -12423,7 +12678,6 @@ "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", diff --git a/package.json b/package.json index dc41358..2e7fdbc 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "expo-image": "~2.4.0", "expo-image-picker": "~16.1.4", "expo-linking": "~7.1.7", + "expo-notifications": "~0.31.5", "expo-router": "~5.1.7", "expo-sharing": "~13.1.5", "expo-splash-screen": "~0.30.10", @@ -48,7 +49,9 @@ "react-native-video": "^6.18.0", "react-native-video-trimmer-ui": "github:adithya1012/react-native-video-trimmer-ui", "react-native-web": "~0.20.0", - "react-native-webview": "13.13.5" + "react-native-webview": "13.13.5", + "react-native-zip-archive": "^7.0.2", + "tus-js-client": "^4.3.1" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -58,4 +61,4 @@ "typescript": "~5.8.3" }, "private": true -} \ No newline at end of file +} diff --git a/react-native-zip-archive.d.ts b/react-native-zip-archive.d.ts new file mode 100644 index 0000000..1a99bc3 --- /dev/null +++ b/react-native-zip-archive.d.ts @@ -0,0 +1,3 @@ +declare module "react-native-zip-archive" { + export function zip(source: string, target: string): Promise; +} diff --git a/tsconfig.json b/tsconfig.json index b29ddea..37f4b5e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "include": [ "**/*.ts", "**/*.tsx", + "**/*.d.ts", ".expo/types/**/*.ts", "expo-env.d.ts" ], diff --git a/utils/localNotification.ts b/utils/localNotification.ts new file mode 100644 index 0000000..24aebf3 --- /dev/null +++ b/utils/localNotification.ts @@ -0,0 +1,135 @@ +import { Alert, Platform } from "react-native"; + +type NotificationsModule = typeof import("expo-notifications"); + +let notificationSetupDone = false; +let notificationsModuleCache: NotificationsModule | null | undefined; + +const BUG_REPORT_NOTIFICATION_CATEGORY = "bug-report-upload"; +const BUG_REPORT_OPEN_ACTION = "open-github-ticket"; + +async function getNotificationsModule(): Promise { + if (notificationsModuleCache !== undefined) { + return notificationsModuleCache; + } + + try { + notificationsModuleCache = await import("expo-notifications"); + } catch (error) { + notificationsModuleCache = null; + console.warn( + "[BugReportNotification] expo-notifications native module unavailable. Rebuild app to enable local notifications.", + error + ); + } + + return notificationsModuleCache; +} + +async function ensureNotificationSetup(): Promise { + const Notifications = await getNotificationsModule(); + if (!Notifications) { + return false; + } + + if (!notificationSetupDone) { + Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), + }); + + if (Platform.OS === "android") { + await Notifications.setNotificationChannelAsync("bug-report-upload", { + name: "Bug Report Upload", + importance: Notifications.AndroidImportance.DEFAULT, + }); + } + + await Notifications.setNotificationCategoryAsync(BUG_REPORT_NOTIFICATION_CATEGORY, [ + { + identifier: BUG_REPORT_OPEN_ACTION, + buttonTitle: "Open GitHub", + options: { + opensAppToForeground: true, + }, + }, + ]); + + notificationSetupDone = true; + } + + const currentPermissions = await Notifications.getPermissionsAsync(); + if (currentPermissions.granted || currentPermissions.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL) { + return true; + } + + const requestResult = await Notifications.requestPermissionsAsync(); + return ( + requestResult.granted || + requestResult.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL + ); +} + +export async function notifyBackgroundBugReportResult( + success: boolean, + issueUrl?: string | null +): Promise { + try { + const ready = await ensureNotificationSetup(); + if (!ready) { + Alert.alert( + success + ? "Bug report uploaded successfully." + : "Bug report upload failed. Please try again." + ); + return; + } + + const Notifications = await getNotificationsModule(); + if (!Notifications) { + Alert.alert( + success + ? "Bug report uploaded successfully." + : "Bug report upload failed. Please try again." + ); + return; + } + + await Notifications.scheduleNotificationAsync({ + content: { + title: "Bug Report Upload", + body: success + ? "Bug report uploaded successfully." + : "Bug report upload failed. Please try again.", + data: issueUrl ? { issueUrl } : undefined, + categoryIdentifier: success && issueUrl ? BUG_REPORT_NOTIFICATION_CATEGORY : undefined, + }, + trigger: null, + }); + } catch (error) { + console.error("[BugReportNotification] Failed to show local notification", error); + } +} + +export async function openBugReportNotificationUrl(issueUrl?: string | null): Promise { + if (!issueUrl) return; + + try { + const canOpen = await import("expo-linking").then((mod) => mod.canOpenURL(issueUrl)); + if (!canOpen) { + Alert.alert("Unable to open GitHub ticket."); + return; + } + + await import("expo-linking").then((mod) => mod.openURL(issueUrl)); + } catch (error) { + console.error("[BugReportNotification] Failed to open GitHub URL", error); + Alert.alert("Unable to open GitHub ticket."); + } +} + +export const BUG_REPORT_NOTIFICATION_OPEN_ACTION = BUG_REPORT_OPEN_ACTION; diff --git a/utils/reportIssue.ts b/utils/reportIssue.ts new file mode 100644 index 0000000..94544a9 --- /dev/null +++ b/utils/reportIssue.ts @@ -0,0 +1,354 @@ +import { + buildDraftsAttachment, + ReportIssueAttachment, +} from "@/utils/reportIssueAttachment"; +import { uploadIssueZipViaTus } from "@/utils/reportIssueTusUpload"; + +// const REPORT_ISSUE_ENDPOINT = "http://localhost:3000/api/report_issue"; +const REPORT_ISSUE_ENDPOINT = "https://pulse-vault.opensource.mieweb.org/api/report_issue"; +const REPORT_ISSUE_TUS_ENDPOINT = "https://pulse-vault.opensource.mieweb.org/api/report_issue/tus"; +// const REPORT_ISSUE_ENDPOINT = "http://localhost:3000/api/report_issue"; +// const REPORT_ISSUE_TUS_ENDPOINT = "http://localhost:3000/api/report_issue/tus"; + +const PREPARING_START_PROGRESS = 0; +const PREPARING_DONE_PROGRESS = 0.35; +const UPLOAD_START_PROGRESS = 0.35; +const UPLOAD_DONE_PROGRESS = 0.95; +const FINALIZING_START_PROGRESS = 0.96; +const FINALIZING_DONE_PROGRESS = 1; + +export interface SubmitIssueReportInput { + summary: string; + description: string; + includeDraftFolder: boolean; +} + +export interface PreparedIssueReportPayload { + includeDraftFolder: boolean; + reportId: string; + attachment: ReportIssueAttachment | null; +} + +export interface UploadPreparedIssueReportInput { + summary: string; + description: string; +} + +export interface SubmitIssueReportResult { + success: boolean; + attachedFileName?: string; + issueNumber?: number; + issueUrl?: string; + uploadId?: string | null; + downloadUrl?: string | null; +} + +export type SubmitIssueReportProgressPhase = + | "preparing-payload" + | "uploading" + | "finalizing"; + +export interface SubmitIssueReportProgress { + phase: SubmitIssueReportProgressPhase; + progress: number; + loaded?: number; + total?: number; + message?: string; +} + +export type SubmitIssueReportProgressCallback = ( + progress: SubmitIssueReportProgress +) => void; + +interface ReportIssueApiResponse { + success?: boolean; + issueNumber?: number; + issueUrl?: string; + uploadId?: string | null; + downloadUrl?: string | null; +} + +function clampProgress(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value <= 0) return 0; + if (value >= 1) return 1; + return value; +} + +function emitProgress( + onProgress: SubmitIssueReportProgressCallback | undefined, + progress: SubmitIssueReportProgress +): void { + if (!onProgress) return; + onProgress({ + ...progress, + progress: clampProgress(progress.progress), + }); +} + +function postFormData( + url: string, + formData: FormData +): Promise<{ status: number; responseText: string }> { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.open("POST", url); + + xhr.onload = () => { + resolve({ + status: xhr.status, + responseText: xhr.responseText ?? "", + }); + }; + + xhr.onerror = () => { + reject(new Error("Network error while submitting issue report.")); + }; + + xhr.onabort = () => { + reject(new Error("Issue report upload was aborted.")); + }; + + xhr.send(formData); + }); +} + +function createReportId(): string { + return `report-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +function validateSubmitInput(input: SubmitIssueReportInput): { + summary: string; + description: string; +} { + const summary = input.summary.trim(); + const description = input.description.trim(); + + if (!summary) { + throw new Error("Summary is required."); + } + + if (!description) { + throw new Error("Description is required."); + } + + return { summary, description }; +} + +async function cleanupAttachment(attachment: ReportIssueAttachment | null): Promise { + if (!attachment) return; + + try { + const fileSystem = await import("expo-file-system"); + await fileSystem.deleteAsync(attachment.uri, { idempotent: true }); + } catch { + // non-blocking cleanup + } +} + +export async function discardPreparedIssueReportPayload( + preparedPayload: PreparedIssueReportPayload | null +): Promise { + await cleanupAttachment(preparedPayload?.attachment ?? null); +} + +// Two-phase flow: +// Phase 1 prepares metadata + optional draft attachment zip. +// Phase 2 performs upload/finalization and can run detached from modal lifecycle. +export async function prepareIssueReportPayload( + input: SubmitIssueReportInput, + onProgress?: SubmitIssueReportProgressCallback +): Promise { + validateSubmitInput(input); + + emitProgress(onProgress, { + phase: "preparing-payload", + progress: PREPARING_START_PROGRESS, + message: "Preparing payload.", + }); + + const reportId = createReportId(); + + let attachment: ReportIssueAttachment | null = null; + if (input.includeDraftFolder) { + emitProgress(onProgress, { + phase: "preparing-payload", + progress: 0.15, + message: "Creating draft attachment.", + }); + attachment = await buildDraftsAttachment(); + if (!attachment) { + emitProgress(onProgress, { + phase: "preparing-payload", + progress: 0.22, + message: "No drafts found. Continuing without draft attachment.", + }); + console.log("[ReportIssue] No drafts available; submitting without draft attachment."); + } + } + + emitProgress(onProgress, { + phase: "preparing-payload", + progress: PREPARING_DONE_PROGRESS, + message: "Payload ready.", + }); + + return { + includeDraftFolder: input.includeDraftFolder, + reportId, + attachment, + }; +} + +export async function uploadPreparedIssueReport( + preparedPayload: PreparedIssueReportPayload, + input: UploadPreparedIssueReportInput, + onProgress?: SubmitIssueReportProgressCallback +): Promise { + const { summary, description } = validateSubmitInput({ + summary: input.summary, + description: input.description, + includeDraftFolder: preparedPayload.includeDraftFolder, + }); + + const formData = new FormData(); + formData.append("summary", summary); + formData.append("description", description); + formData.append("reportId", preparedPayload.reportId); + + const { reportId, attachment } = preparedPayload; + + // Current backend contract expects attachment upload metadata in this final POST, + // so true "issue-first then attachment" flow requires a server-side API change. + + emitProgress(onProgress, { + phase: "uploading", + progress: UPLOAD_START_PROGRESS, + loaded: 0, + message: "Uploading issue report.", + }); + + try { + if (attachment) { + const attachmentType = attachment.type || "application/zip"; + const attachmentName = attachment.name || `issue-report-${Date.now()}.zip`; + + const tusUploadResult = await uploadIssueZipViaTus({ + endpoint: REPORT_ISSUE_TUS_ENDPOINT, + fileUri: attachment.uri, + fileName: attachmentName, + fileType: attachmentType, + reportId, + onProgress: ({ bytesUploaded, bytesTotal }) => { + const uploadProgress = + typeof bytesTotal === "number" && bytesTotal > 0 + ? bytesUploaded / bytesTotal + : 0; + + const globalProgress = + UPLOAD_START_PROGRESS + + (UPLOAD_DONE_PROGRESS - UPLOAD_START_PROGRESS) * + clampProgress(uploadProgress); + + emitProgress(onProgress, { + phase: "uploading", + progress: globalProgress, + loaded: bytesUploaded, + total: bytesTotal, + }); + }, + onNotice: (message) => { + emitProgress(onProgress, { + phase: "uploading", + progress: UPLOAD_START_PROGRESS, + message, + }); + }, + }); + + formData.append("zipUploadMode", "tus"); + formData.append("zipFileName", attachmentName); + formData.append("zipContentType", attachmentType); + formData.append("zipTusUploadId", tusUploadResult.uploadId); + formData.append("zipTusUploadUrl", tusUploadResult.uploadUrl); + + emitProgress(onProgress, { + phase: "uploading", + progress: UPLOAD_DONE_PROGRESS, + loaded: 1, + total: 1, + message: "Upload complete. Submitting report details.", + }); + } else { + emitProgress(onProgress, { + phase: "uploading", + progress: UPLOAD_DONE_PROGRESS, + loaded: 1, + total: 1, + message: "Submitting report details.", + }); + } + + const { status, responseText } = await postFormData(REPORT_ISSUE_ENDPOINT, formData); + + emitProgress(onProgress, { + phase: "finalizing", + progress: FINALIZING_START_PROGRESS, + message: "Finalizing issue report.", + }); + + if (status < 200 || status >= 300) { + const errorText = responseText || ""; + const message = errorText + ? `Failed to report issue (${status}): ${errorText}` + : `Failed to report issue (${status}).`; + throw new Error(message); + } + + let responseJson: ReportIssueApiResponse | null = null; + if (responseText) { + try { + responseJson = JSON.parse(responseText) as ReportIssueApiResponse; + } catch { + responseJson = null; + } + } + + if (responseJson && responseJson.success === false) { + throw new Error("Issue report was not accepted by the server."); + } + + emitProgress(onProgress, { + phase: "finalizing", + progress: FINALIZING_DONE_PROGRESS, + message: "Issue report submitted.", + }); + + return { + success: responseJson?.success ?? true, + attachedFileName: attachment?.name, + issueNumber: responseJson?.issueNumber, + issueUrl: responseJson?.issueUrl, + uploadId: responseJson?.uploadId ?? null, + downloadUrl: responseJson?.downloadUrl ?? null, + }; + } finally { + await cleanupAttachment(attachment); + } +} + +export async function submitIssueReport( + input: SubmitIssueReportInput, + onProgress?: SubmitIssueReportProgressCallback +): Promise { + const preparedPayload = await prepareIssueReportPayload(input, onProgress); + return uploadPreparedIssueReport( + preparedPayload, + { + summary: input.summary, + description: input.description, + }, + onProgress + ); +} diff --git a/utils/reportIssueAttachment.ts b/utils/reportIssueAttachment.ts new file mode 100644 index 0000000..551f3eb --- /dev/null +++ b/utils/reportIssueAttachment.ts @@ -0,0 +1,201 @@ +import { DraftStorage } from "@/utils/draftStorage"; +import { fileStore } from "@/utils/fileStore"; +import * as FileSystem from "expo-file-system"; +import { zip } from "react-native-zip-archive"; + +export interface ReportIssueAttachment { + uri: string; + name: string; + type: string; +} + +const DRAFTS_DIR_RELATIVE = "pulse/drafts/"; + +function normalizeToFileUri(path: string): string { + if (!path) return path; + if (path.startsWith("file://")) return path; + if (path.startsWith("/")) return `file://${path}`; + return path; +} + +async function ensureDir(path: string): Promise { + const info = await FileSystem.getInfoAsync(path); + if (!info.exists) { + await FileSystem.makeDirectoryAsync(path, { intermediates: true }); + } +} + +function randomSuffix(): string { + return `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +async function getDraftIdsFromFileSystem(): Promise { + const draftsRoot = `${FileSystem.documentDirectory}${DRAFTS_DIR_RELATIVE}`; + const draftsRootInfo = await FileSystem.getInfoAsync(draftsRoot); + if (!draftsRootInfo.exists) { + return []; + } + + const entries = await FileSystem.readDirectoryAsync(draftsRoot); + const ids = entries + .map((entry) => entry.replace(/\/$/, "")) + .filter((entry) => entry.length > 0); + + return Array.from(new Set(ids)); +} + +export async function buildDraftsAttachment(): Promise { + const startTs = Date.now(); + console.log("[ReportIssueAttachment] Building draft zip attachment..."); + + const drafts = await DraftStorage.getAllDrafts(); + const fileSystemDraftIds = await getDraftIdsFromFileSystem(); + const draftIds = Array.from( + new Set([...drafts.map((draft) => draft.id), ...fileSystemDraftIds]) + ); + + console.log( + `[ReportIssueAttachment] Drafts from metadata=${drafts.length}, filesystem=${fileSystemDraftIds.length}, merged=${draftIds.length}` + ); + + if (draftIds.length === 0) { + console.log("[ReportIssueAttachment] No drafts found. Skipping attachment."); + return null; + } + + const reportsDir = `${FileSystem.cacheDirectory}reports/`; + await ensureDir(reportsDir); + + const buildId = randomSuffix(); + const stagingDir = `${reportsDir}report-build-${buildId}/`; + await ensureDir(stagingDir); + + const cleanupPaths = [stagingDir]; + + const zipFileName = `pulse-report-drafts-${Date.now()}.zip`; + const zipOutputPath = `${reportsDir}${zipFileName}`; + const normalizedZipOutputPath = normalizeToFileUri(zipOutputPath); + + const outputInfo = await FileSystem.getInfoAsync(zipOutputPath); + if (outputInfo.exists) { + await FileSystem.deleteAsync(zipOutputPath, { idempotent: true }); + } + + try { + const metadata = drafts + .slice() + .sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()) + .map((draft) => ({ + id: draft.id, + mode: draft.mode, + name: draft.name ?? null, + createdAt: draft.createdAt.toISOString(), + lastModified: draft.lastModified.toISOString(), + maxDurationLimitSeconds: draft.maxDurationLimitSeconds, + segmentCount: draft.segments.length, + })); + + const metadataPath = `${stagingDir}metadata.json`; + await FileSystem.writeAsStringAsync( + metadataPath, + JSON.stringify( + { + drafts: metadata, + discoveredDraftFolderIds: draftIds, + }, + null, + 2 + ), + { encoding: FileSystem.EncodingType.UTF8 } + ); + + let copiedFileCount = 0; + + for (const draftId of draftIds) { + const absoluteFiles = await fileStore.getDraftFiles(draftId); + console.log( + `[ReportIssueAttachment] Draft ${draftId}: found ${absoluteFiles.length} file(s) to include` + ); + + for (const absolutePath of absoluteFiles) { + const info = await FileSystem.getInfoAsync(absolutePath); + if (!info.exists) { + console.warn(`[ReportIssueAttachment] Missing source file, skipping: ${absolutePath}`); + continue; + } + + const relativePath = fileStore.toRelativePath(absolutePath); + const targetPath = `${stagingDir}${relativePath}`; + const targetDir = targetPath.substring(0, targetPath.lastIndexOf("/")); + await ensureDir(targetDir); + await FileSystem.copyAsync({ from: absolutePath, to: targetPath }); + copiedFileCount += 1; + } + } + + console.log( + `[ReportIssueAttachment] Copied ${copiedFileCount} file(s) into staging: ${stagingDir}` + ); + + console.log( + `[ReportIssueAttachment] Zipping staging directory -> ${normalizedZipOutputPath}` + ); + + let zipUri: string; + try { + zipUri = await zip(stagingDir, normalizedZipOutputPath); + } catch (error) { + console.error("[ReportIssueAttachment] Native zip operation failed", { + stagingDir, + normalizedZipOutputPath, + error, + }); + throw new Error("Failed to create draft zip attachment (native zip failed)."); + } + + const finalUriCandidates = Array.from( + new Set([ + zipUri, + normalizeToFileUri(zipUri), + zipOutputPath, + normalizedZipOutputPath, + ]) + ).filter((candidate) => !!candidate); + + let finalUri: string | null = null; + for (const candidate of finalUriCandidates) { + const info = await FileSystem.getInfoAsync(candidate); + console.log( + `[ReportIssueAttachment] Zip candidate check exists=${info.exists} path=${candidate}` + ); + if (info.exists) { + finalUri = candidate; + break; + } + } + + if (!finalUri) { + throw new Error( + `Failed to create draft zip attachment. zip() returned: ${zipUri}` + ); + } + + const zipInfo = await FileSystem.getInfoAsync(finalUri); + const zipSize = "size" in zipInfo && typeof zipInfo.size === "number" ? zipInfo.size : 0; + console.log( + `[ReportIssueAttachment] Zip ready (${zipSize} bytes) at ${finalUri} in ${Date.now() - startTs}ms` + ); + + return { + uri: finalUri, + name: zipFileName, + type: "application/zip", + }; + } finally { + await Promise.all( + cleanupPaths.map((path) => + FileSystem.deleteAsync(path, { idempotent: true }).catch(() => undefined) + ) + ); + } +} diff --git a/utils/reportIssueTusUpload.ts b/utils/reportIssueTusUpload.ts new file mode 100644 index 0000000..0d30b3a --- /dev/null +++ b/utils/reportIssueTusUpload.ts @@ -0,0 +1,411 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import * as FileSystem from "expo-file-system"; +import * as tus from "tus-js-client"; + +export interface ReportIssueTusProgress { + bytesUploaded: number; + bytesTotal: number; +} + +export interface ReportIssueTusUploadInput { + fileUri: string; + fileName: string; + fileType: string; + endpoint: string; + reportId: string; + onProgress?: (progress: ReportIssueTusProgress) => void; + onNotice?: (message: string) => void; +} + +export interface ReportIssueTusUploadResult { + uploadUrl: string; + uploadId: string; +} + +const URL_STORAGE_PREFIX = "issue-report-tus:"; +const UPLOAD_ACTIVITY_TIMEOUT_MS = 30_000; +const TUS_RESUMABLE_VERSION = "1.0.0"; + +interface StoredUploadEntry { + fingerprint: string; + upload: { + size?: number; + metadata?: Record; + creationTime?: string; + uploadUrl?: string; + urlStorageKey?: string; + }; +} + +class AsyncStorageUrlStorage { + async addUpload(fingerprint: string, upload: StoredUploadEntry["upload"]): Promise { + const storageKey = `${URL_STORAGE_PREFIX}${Date.now()}:${Math.random().toString(16).slice(2)}`; + const payload: StoredUploadEntry = { + fingerprint, + upload: { + ...upload, + urlStorageKey: storageKey, + }, + }; + await AsyncStorage.setItem(storageKey, JSON.stringify(payload)); + return storageKey; + } + + async findUploadsByFingerprint(fingerprint: string): Promise { + const keys = await AsyncStorage.getAllKeys(); + const tusKeys = keys.filter((key) => key.startsWith(URL_STORAGE_PREFIX)); + if (tusKeys.length === 0) return []; + + const records = await AsyncStorage.multiGet(tusKeys); + const matches: StoredUploadEntry["upload"][] = []; + + for (const [key, raw] of records) { + if (!raw) continue; + try { + const parsed = JSON.parse(raw) as StoredUploadEntry; + if (parsed.fingerprint !== fingerprint) continue; + matches.push({ + ...parsed.upload, + urlStorageKey: parsed.upload.urlStorageKey ?? key, + }); + } catch { + continue; + } + } + + return matches; + } + + async removeUpload(urlStorageKey: string): Promise { + await AsyncStorage.removeItem(urlStorageKey); + } +} + +function parseUploadId(uploadUrl: string): string { + try { + const url = new URL(uploadUrl); + const segments = url.pathname.split("/").filter(Boolean); + return segments[segments.length - 1] ?? uploadUrl; + } catch { + const segments = uploadUrl.split("/").filter(Boolean); + return segments[segments.length - 1] ?? uploadUrl; + } +} + +function getHttpStatus(error: unknown): number | undefined { + const originalResponse = (error as { originalResponse?: { getStatus?: () => number } }) + ?.originalResponse; + + const maybeStatus = originalResponse?.getStatus; + if (typeof maybeStatus === "function") { + try { + const value = maybeStatus.call(originalResponse); + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } catch { + // continue with fallback parsing + } + } + + const message = error instanceof Error ? error.message : ""; + const statusMatch = message.match(/response code:\s*(\d{3})/i) ?? message.match(/\b(\d{3})\b/); + if (statusMatch) { + const parsed = Number(statusMatch[1]); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return undefined; +} + +function isLikelyNetworkError(error: unknown): boolean { + const message = error instanceof Error ? error.message.toLowerCase() : ""; + return ( + message.includes("network") || + message.includes("offline") || + message.includes("failed to fetch") || + message.includes("connection") + ); +} + +function isRetryableTusError(error: unknown): boolean { + const maybeRetryable = ( + tus.Upload as unknown as { + isRetryableError?: (inputError: unknown) => boolean; + } + ).isRetryableError; + + if (typeof maybeRetryable === "function") { + return maybeRetryable(error); + } + + const status = getHttpStatus(error); + if (typeof status !== "number") { + return false; + } + + return status >= 500 || status === 409 || status === 423 || status === 429; +} + +function toUploadError(error: unknown): Error { + if (!(error instanceof Error)) { + return new Error("Issue attachment upload failed."); + } + + const status = getHttpStatus(error); + if (status === 401 || status === 403) { + return new Error( + "Issue attachment upload was rejected by the server (403). Please verify report-upload authorization on the TUS endpoint." + ); + } + + if (typeof status === "number" && status >= 400 && status < 500) { + return new Error(`Issue attachment upload failed with status ${status}.`); + } + + return error; +} + +function createFingerprint(params: { + fileUri: string; + fileName: string; + fileSize: number; + reportId: string; +}): string { + return [ + "issue-report", + params.reportId, + params.fileName, + params.fileSize, + params.fileUri, + ].join(":"); +} + +function normalizeTusEndpoint(endpoint: string): string { + return endpoint.replace(/\/$/, ""); +} + +async function startTusUpload( + upload: tus.Upload, + fingerprintStorage: AsyncStorageUrlStorage, + allowResume: boolean +): Promise<{ uploadUrl: string; resumed: boolean }> { + let resumed = false; + let resumedStorageKey: string | null = null; + + if (allowResume) { + const previousUploads = await upload.findPreviousUploads(); + if (previousUploads.length > 0) { + const previousUpload = previousUploads[0]; + resumed = true; + resumedStorageKey = previousUpload.urlStorageKey ?? null; + upload.resumeFromPreviousUpload(previousUpload); + } + } + + return new Promise((resolve, reject) => { + let isSettled = false; + let activityTimeout: ReturnType | null = null; + + const clearActivityTimeout = () => { + if (activityTimeout) { + clearTimeout(activityTimeout); + activityTimeout = null; + } + }; + + const touchActivity = () => { + clearActivityTimeout(); + activityTimeout = setTimeout(() => { + if (isSettled) return; + isSettled = true; + upload.abort().catch(() => undefined); + reject( + new Error( + "Issue attachment upload timed out due to no network activity. Please check connection and try again." + ) + ); + }, UPLOAD_ACTIVITY_TIMEOUT_MS); + }; + + touchActivity(); + + upload.options.onSuccess = () => { + if (isSettled) return; + isSettled = true; + clearActivityTimeout(); + + if (!upload.url) { + reject(new Error("TUS upload completed without an upload URL.")); + return; + } + + resolve({ + uploadUrl: upload.url, + resumed, + }); + }; + + upload.options.onError = async (error: Error) => { + if (isSettled) return; + isSettled = true; + clearActivityTimeout(); + + const status = getHttpStatus(error); + if (status === 404 && resumed && resumedStorageKey) { + await fingerprintStorage.removeUpload(resumedStorageKey); + } + reject(error); + }; + + const previousProgressHandler = upload.options.onProgress; + upload.options.onProgress = (bytesUploaded, bytesTotal) => { + touchActivity(); + previousProgressHandler?.(bytesUploaded, bytesTotal); + }; + + const previousBeforeRequest = upload.options.onBeforeRequest; + upload.options.onBeforeRequest = async (req) => { + touchActivity(); + req.setHeader("Tus-Resumable", TUS_RESUMABLE_VERSION); + console.log( + `[ReportIssueTUS] ${req.getMethod()} ${req.getURL()} starting (Tus-Resumable=${req.getHeader("Tus-Resumable") ?? "missing"})` + ); + if (previousBeforeRequest) { + await previousBeforeRequest(req); + } + }; + + const previousAfterResponse = upload.options.onAfterResponse; + upload.options.onAfterResponse = async (req, res) => { + touchActivity(); + console.log( + `[ReportIssueTUS] ${req.getMethod()} ${req.getURL()} -> ${res.getStatus()}` + ); + if (previousAfterResponse) { + await previousAfterResponse(req, res); + } + }; + + try { + upload.start(); + } catch (error) { + if (isSettled) return; + isSettled = true; + clearActivityTimeout(); + reject(error instanceof Error ? error : new Error("Failed to start TUS upload.")); + } + }); +} + +export async function uploadIssueZipViaTus( + input: ReportIssueTusUploadInput +): Promise { + console.log("[ReportIssueTUS] Starting issue attachment upload..."); + const fileInfo = await FileSystem.getInfoAsync(input.fileUri); + if (!fileInfo.exists) { + throw new Error("Issue attachment file not found."); + } + + const fileSize = "size" in fileInfo && typeof fileInfo.size === "number" ? fileInfo.size : 0; + if (fileSize <= 0) { + throw new Error("Issue attachment file is empty."); + } + console.log(`[ReportIssueTUS] File size: ${fileSize} bytes`); + + const endpoint = normalizeTusEndpoint(input.endpoint); + console.log(`[ReportIssueTUS] Endpoint: ${endpoint}`); + const urlStorage = new AsyncStorageUrlStorage(); + const fingerprint = createFingerprint({ + fileUri: input.fileUri, + fileName: input.fileName, + fileSize, + reportId: input.reportId, + }); + + const metadata: Record = { + filename: input.fileName, + reportId: input.reportId, + contentType: input.fileType, + }; + + const makeUpload = () => + new tus.Upload( + { + uri: input.fileUri, + name: input.fileName, + type: input.fileType, + } as unknown as Blob, + { + endpoint, + headers: { + "tus-resumable": TUS_RESUMABLE_VERSION, + }, + metadata, + uploadSize: fileSize, + storeFingerprintForResuming: true, + removeFingerprintOnSuccess: true, + retryDelays: [0, 500, 1000, 2000, 4000, 8000, 16000], + urlStorage: urlStorage as unknown as tus.UrlStorage, + fingerprint: () => Promise.resolve(fingerprint), + onProgress: (bytesUploaded, bytesTotal) => { + input.onProgress?.({ + bytesUploaded, + bytesTotal, + }); + }, + onShouldRetry: (error, retryAttempt) => { + const status = getHttpStatus(error); + + if (typeof status === "number" && status >= 400 && status < 500) { + return false; + } + + if (retryAttempt > 10) { + return false; + } + + if (status === 404) { + return false; + } + + return isLikelyNetworkError(error) || isRetryableTusError(error); + }, + } + ); + + try { + const initialUpload = makeUpload(); + console.log("[ReportIssueTUS] Attempting resume or fresh upload..."); + const result = await startTusUpload(initialUpload, urlStorage, true); + console.log(`[ReportIssueTUS] Upload completed. URL: ${result.uploadUrl}`); + return { + uploadUrl: result.uploadUrl, + uploadId: parseUploadId(result.uploadUrl), + }; + } catch (error) { + if (getHttpStatus(error) !== 404) { + throw toUploadError(error); + } + + input.onNotice?.("Previous upload expired. Retrying from start..."); + console.log("[ReportIssueTUS] Resume upload returned 404. Restarting from zero."); + + const staleUploads = await urlStorage.findUploadsByFingerprint(fingerprint); + await Promise.all( + staleUploads.map((entry) => + entry.urlStorageKey ? urlStorage.removeUpload(entry.urlStorageKey) : Promise.resolve() + ) + ); + + const retryUpload = makeUpload(); + const retryResult = await startTusUpload(retryUpload, urlStorage, false); + console.log(`[ReportIssueTUS] Retry upload completed. URL: ${retryResult.uploadUrl}`); + return { + uploadUrl: retryResult.uploadUrl, + uploadId: parseUploadId(retryResult.uploadUrl), + }; + } +}