From 455fad3178a3c8b22ab49738d36bad307668a71b Mon Sep 17 00:00:00 2001 From: darui3018823 <151906210+darui3018823@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:56:25 +0900 Subject: [PATCH 1/5] fix(form): reject amounts that exceed the safe integer range Amounts above Number.MAX_SAFE_INTEGER are silently rounded by IEEE 754 doubles (e.g. 1145141919364364810 became ...800), so validate against MAX_AMOUNT in the add/edit forms and show a dedicated error instead of storing a corrupted value. Co-Authored-By: Claude Fable 5 --- app/add.tsx | 6 +++++- app/edit.tsx | 6 +++++- i18n/locales/en.json | 1 + i18n/locales/ja.json | 1 + lib/currency.ts | 6 ++++++ 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/add.tsx b/app/add.tsx index 6ece3ac..5340cfe 100644 --- a/app/add.tsx +++ b/app/add.tsx @@ -18,7 +18,7 @@ import { } from '../lib/subscriptionIcon'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); -import { CURRENCIES, parseAmountInput } from '../lib/currency'; +import { CURRENCIES, parseAmountInput, MAX_AMOUNT } from '../lib/currency'; const ICON_PICKER_WIDTH = Math.min(SCREEN_WIDTH - 32, 360); const ICON_PICKER_GAP = 10; @@ -63,6 +63,10 @@ export default function AddSubscriptionModal() { Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_invalid_amount')); return; } + if (parsedAmount > MAX_AMOUNT) { + Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_amount_too_large')); + return; + } setIsSubmitting(true); try { diff --git a/app/edit.tsx b/app/edit.tsx index a2e52d4..701cea8 100644 --- a/app/edit.tsx +++ b/app/edit.tsx @@ -11,7 +11,7 @@ import { setCropHandler } from '../lib/imageCropStore'; import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import { useTranslation } from 'react-i18next'; import { usePaymentMethodStore } from '../store/usePaymentMethodStore'; -import { CURRENCIES, CurrencyId, parseAmountInput } from '../lib/currency'; +import { CURRENCIES, CurrencyId, parseAmountInput, MAX_AMOUNT } from '../lib/currency'; import { isSubscriptionPresetIconValue, parseSubscriptionPresetIconValue, @@ -85,6 +85,10 @@ function EditSubscriptionForm({ subscription }: { subscription: Subscription }) Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_invalid_amount')); return; } + if (parsedAmount > MAX_AMOUNT) { + Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_amount_too_large')); + return; + } setIsSubmitting(true); try { diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 42de55c..69ff118 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -266,6 +266,7 @@ "error_title": "Input Error", "error_required": "Service name and amount are required.", "error_invalid_amount": "Amount must be a number of 0 or more.", + "error_amount_too_large": "Amount is too large to be stored accurately. Please enter a smaller value.", "service_name": "Service Name", "service_name_placeholder": "e.g. Netflix", "plan_name": "Plan Name", diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index 6c71c8a..6324c33 100644 --- a/i18n/locales/ja.json +++ b/i18n/locales/ja.json @@ -266,6 +266,7 @@ "error_title": "入力エラー", "error_required": "サービス名と料金は必須です", "error_invalid_amount": "料金は0以上の数値で入力してください", + "error_amount_too_large": "料金が大きすぎて正確に保存できません。より小さい値を入力してください", "service_name": "サービス名", "service_name_placeholder": "例: Netflix", "plan_name": "プラン名", diff --git a/lib/currency.ts b/lib/currency.ts index b307ad3..d468405 100644 --- a/lib/currency.ts +++ b/lib/currency.ts @@ -23,6 +23,12 @@ const REGION_TO_CURRENCY: Record = { FI: 'EUR', IE: 'EUR', GR: 'EUR', LU: 'EUR', }; +// The largest amount that can survive the round trip through IEEE 754 +// doubles (JS numbers / JSON). Anything above this is silently rounded +// (e.g. 1145141919364364810 becomes …800), so inputs beyond it must be +// rejected instead of stored corrupted. +export const MAX_AMOUNT = Number.MAX_SAFE_INTEGER; + // Parse a user-entered amount string. Accepts full-width digits and // thousand separators (e.g. "1,000"). Returns null when the input is // empty or not a valid non-negative finite number. From 170d9c6f503e8684f2d5b314c1eb8fcf4c32ef56 Mon Sep 17 00:00:00 2001 From: darui3018823 <151906210+darui3018823@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:56:38 +0900 Subject: [PATCH 2/5] fix(upload): replace uri-based FormData part with expo-file-system File Expo SDK 54+ replaces global fetch with its WinterCG implementation, which rejects React Native's legacy { uri, name, type } FormData parts with "Unsupported FormDataPart implementation". Icon uploads after cropping hit this on every submit. Append an expo-file-system File (native) or a Blob read back from the picker URI (web) instead, and let the file's own name/mime metadata replace the manual extension-to-mime mapping. Co-Authored-By: Claude Fable 5 --- lib/api.ts | 27 ++++++++++++++------------- package.json | 1 + pnpm-lock.yaml | 5 +++++ 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/lib/api.ts b/lib/api.ts index 1685073..bd88300 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -2,6 +2,7 @@ import * as SecureStore from 'expo-secure-store'; import { Platform, Alert } from 'react-native'; import Constants from 'expo-constants'; import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; +import { File as FileSystemFile } from 'expo-file-system'; const PRODUCTION_URL = 'https://subscription-manager.daruks.com'; const DEV_PORT = 8084; @@ -532,7 +533,7 @@ export const uploadApi = { const token = await getToken(); let uploadUri = uri; let filename = uri.split('/').pop()?.split('?')[0] || 'icon.jpg'; - let ext = (filename.split('.').pop() || 'jpg').toLowerCase(); + const ext = (filename.split('.').pop() || 'jpg').toLowerCase(); // The server only accepts png/jpg/jpeg/gif/webp. iOS photos are often // HEIC/HEIF, so convert those to JPEG before uploading instead of // letting the server reject them with 415. @@ -543,20 +544,20 @@ export const uploadApi = { }); uploadUri = converted.uri; filename = filename.replace(/\.[^.]+$/, '.jpg'); - ext = 'jpg'; } - const mimeTypes: Record = { - png: 'image/png', - gif: 'image/gif', - webp: 'image/webp', - }; - const mimeType = mimeTypes[ext] || 'image/jpeg'; const formData = new FormData(); - formData.append('file', { - uri: uploadUri, - name: filename, - type: mimeType, - } as any); + if (Platform.OS === 'web') { + // No file:// paths on web — the picker hands out blob:/data: + // URIs that fetch can read back into a Blob. + const blob = await (await fetch(uploadUri)).blob(); + formData.append('file', blob, filename); + } else { + // Expo's WinterCG fetch rejects React Native's legacy + // { uri, name, type } parts with "Unsupported FormDataPart + // implementation" — it only accepts strings, Blobs, or objects + // exposing bytes(), like expo-file-system's File. + formData.append('file', new FileSystemFile(uploadUri) as unknown as Blob); + } const response = await fetch(`${API_BASE_URL}/upload/icon`, { method: 'POST', diff --git a/package.json b/package.json index cc089ab..20c26f5 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "expo-auth-session": "^57.0.1", "expo-blur": "^57.0.0", "expo-constants": "~57.0.2", + "expo-file-system": "~57.0.0", "expo-font": "~57.0.0", "expo-haptics": "~57.0.0", "expo-image": "~57.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbb22b3..2fedfed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: expo-constants: specifier: ~57.0.2 version: 57.0.2(expo@57.0.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)) + expo-file-system: + specifier: ~57.0.0 + version: 57.0.0(expo@57.0.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)) expo-font: specifier: ~57.0.0 version: 57.0.0(expo@57.0.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) @@ -5697,7 +5700,9 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.74.89': {} From 19d3b65baf5ca9fccbbdc69b93ab32ba86b371ce Mon Sep 17 00:00:00 2001 From: darui3018823 <151906210+darui3018823@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:57:19 +0900 Subject: [PATCH 3/5] feat(edit): confirm before discarding unsaved changes Closing the edit screen with the x button (or back gesture) silently dropped any edits. Intercept beforeRemove while the form differs from the stored subscription and ask the user to keep editing or discard; a successful save skips the guard. Co-Authored-By: Claude Fable 5 --- app/edit.tsx | 44 ++++++++++++++++++++++++++++++++++++++++++-- i18n/locales/en.json | 6 +++++- i18n/locales/ja.json | 6 +++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/app/edit.tsx b/app/edit.tsx index 701cea8..bbb5cb1 100644 --- a/app/edit.tsx +++ b/app/edit.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { View, Text, TextInput, Pressable, useColorScheme, KeyboardAvoidingView, ScrollView, Platform, Alert, ActivityIndicator, Image, Modal, Dimensions } from 'react-native'; -import { Stack, useRouter, useLocalSearchParams } from 'expo-router'; +import { Stack, useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { Ionicons, FontAwesome5 } from '@expo/vector-icons'; import { useSubscriptionStore } from '../store/useSubscriptionStore'; import { useAddFormStore } from '../store/useAddFormStore'; @@ -75,6 +75,45 @@ function EditSubscriptionForm({ subscription }: { subscription: Subscription }) setCurrency((subscription.currency as CurrencyId) || 'JPY'); }, [subscription, setBillingCycle, setPaymentMethod, setCurrency]); + const navigation = useNavigation(); + const isDirty = + serviceName !== subscription.service_name || + planName !== (subscription.plan_name || '') || + amount !== String(subscription.amount) || + memo !== (subscription.memo || '') || + iconUri !== (subscription.icon_url ?? null) || + nextPaymentDate.getTime() !== new Date(subscription.next_payment_date).getTime() || + billingCycle !== subscription.billing_cycle || + paymentMethod !== subscription.payment_method || + currency !== ((subscription.currency as CurrencyId) || 'JPY'); + // Refs so the beforeRemove listener sees current values without + // re-subscribing on every keystroke. + const isDirtyRef = useRef(isDirty); + const skipDirtyGuardRef = useRef(false); + useEffect(() => { + isDirtyRef.current = isDirty; + }, [isDirty]); + + // Confirm before the screen is removed with unsaved edits — covers the + // × button, the Android back button, and the modal swipe-down gesture. + useEffect(() => { + return navigation.addListener('beforeRemove', (e) => { + if (skipDirtyGuardRef.current || !isDirtyRef.current) return; + e.preventDefault(); + Alert.alert(t('edit.discard_title'), t('edit.discard_message'), [ + { text: t('edit.discard_keep'), style: 'cancel' }, + { + text: t('edit.discard_confirm'), + style: 'destructive', + onPress: () => { + skipDirtyGuardRef.current = true; + navigation.dispatch(e.data.action); + }, + }, + ]); + }); + }, [navigation, t]); + const handleSave = async () => { if (!serviceName || !amount) { Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_required')); @@ -113,6 +152,7 @@ function EditSubscriptionForm({ subscription }: { subscription: Subscription }) icon_url: iconUrl, memo: memo.trim() ? memo.trim() : null, }); + skipDirtyGuardRef.current = true; router.back(); } catch (error: any) { Alert.alert(t('common.error'), error.message || t('edit.error_failed')); diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 69ff118..82b554c 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -289,7 +289,11 @@ "title": "Edit", "submit": "Save", "error_failed": "Failed to update.", - "not_found": "Subscription not found." + "not_found": "Subscription not found.", + "discard_title": "Discard changes?", + "discard_message": "You have unsaved changes. Are you sure you want to discard them?", + "discard_confirm": "Discard", + "discard_keep": "Keep Editing" }, "detail": { "edit_button": "Edit", diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index 6324c33..ba1bb55 100644 --- a/i18n/locales/ja.json +++ b/i18n/locales/ja.json @@ -289,7 +289,11 @@ "title": "編集", "submit": "保存", "error_failed": "更新に失敗しました", - "not_found": "サブスクリプションが見つかりません" + "not_found": "サブスクリプションが見つかりません", + "discard_title": "変更を破棄しますか?", + "discard_message": "保存されていない変更があります。破棄してもよろしいですか?", + "discard_confirm": "破棄", + "discard_keep": "編集を続ける" }, "detail": { "edit_button": "編集", From 13221774cc4174bdd18b55b660718df36b37b31c Mon Sep 17 00:00:00 2001 From: darui3018823 <151906210+darui3018823@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:15:02 +0900 Subject: [PATCH 4/5] fix(form): reject rounded over-limit amounts --- app/add.tsx | 10 +++++----- app/edit.tsx | 10 +++++----- lib/currency.ts | 35 +++++++++++++++++++++++++++++++---- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/app/add.tsx b/app/add.tsx index 5340cfe..16c0988 100644 --- a/app/add.tsx +++ b/app/add.tsx @@ -18,7 +18,7 @@ import { } from '../lib/subscriptionIcon'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); -import { CURRENCIES, parseAmountInput, MAX_AMOUNT } from '../lib/currency'; +import { CURRENCIES, isAmountInputAboveMax, parseAmountInput } from '../lib/currency'; const ICON_PICKER_WIDTH = Math.min(SCREEN_WIDTH - 32, 360); const ICON_PICKER_GAP = 10; @@ -58,15 +58,15 @@ export default function AddSubscriptionModal() { Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_required')); return; } + if (isAmountInputAboveMax(amount)) { + Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_amount_too_large')); + return; + } const parsedAmount = parseAmountInput(amount); if (parsedAmount === null) { Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_invalid_amount')); return; } - if (parsedAmount > MAX_AMOUNT) { - Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_amount_too_large')); - return; - } setIsSubmitting(true); try { diff --git a/app/edit.tsx b/app/edit.tsx index 591613e..1cb785d 100644 --- a/app/edit.tsx +++ b/app/edit.tsx @@ -11,7 +11,7 @@ import { setCropHandler } from '../lib/imageCropStore'; import DateTimePicker, { DateTimePickerEvent } from '@react-native-community/datetimepicker'; import { useTranslation } from 'react-i18next'; import { usePaymentMethodStore } from '../store/usePaymentMethodStore'; -import { CURRENCIES, CurrencyId, parseAmountInput, MAX_AMOUNT } from '../lib/currency'; +import { CURRENCIES, CurrencyId, isAmountInputAboveMax, parseAmountInput } from '../lib/currency'; import { isSubscriptionPresetIconValue, parseSubscriptionPresetIconValue, @@ -123,15 +123,15 @@ function EditSubscriptionForm({ subscription }: { subscription: Subscription }) Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_required')); return; } + if (isAmountInputAboveMax(amount)) { + Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_amount_too_large')); + return; + } const parsedAmount = parseAmountInput(amount); if (parsedAmount === null) { Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_invalid_amount')); return; } - if (parsedAmount > MAX_AMOUNT) { - Alert.alert(t('subscription_form.error_title'), t('subscription_form.error_amount_too_large')); - return; - } setIsSubmitting(true); try { diff --git a/lib/currency.ts b/lib/currency.ts index d468405..f701bc9 100644 --- a/lib/currency.ts +++ b/lib/currency.ts @@ -29,14 +29,41 @@ const REGION_TO_CURRENCY: Record = { // rejected instead of stored corrupted. export const MAX_AMOUNT = Number.MAX_SAFE_INTEGER; +function normalizeAmountInput(input: string): string { + return input + .replace(/[0-9.]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0)) + .replace(/[,,]/g, '') + .trim(); +} + +export function isAmountInputAboveMax(input: string): boolean { + const normalized = normalizeAmountInput(input); + if (!normalized) return false; + + const value = Number(normalized); + if (!Number.isFinite(value) || value < 0) return false; + if (value > MAX_AMOUNT) return true; + + const decimalMatch = normalized.match(/^(\d+)(?:\.(\d*))?$/); + if (!decimalMatch) return false; + + const integerPart = decimalMatch[1].replace(/^0+(?=\d)/, ''); + const maxInteger = String(MAX_AMOUNT); + if (integerPart.length !== maxInteger.length) { + return integerPart.length > maxInteger.length; + } + if (integerPart !== maxInteger) { + return integerPart > maxInteger; + } + + return /[1-9]/.test(decimalMatch[2] ?? ''); +} + // Parse a user-entered amount string. Accepts full-width digits and // thousand separators (e.g. "1,000"). Returns null when the input is // empty or not a valid non-negative finite number. export function parseAmountInput(input: string): number | null { - const normalized = input - .replace(/[0-9.]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0)) - .replace(/[,,]/g, '') - .trim(); + const normalized = normalizeAmountInput(input); if (!normalized) return null; const value = Number(normalized); if (!Number.isFinite(value) || value < 0) return null; From 7ae72e52e76edbc681d127d609ed60eda24fafc6 Mon Sep 17 00:00:00 2001 From: darui3018823 <151906210+darui3018823@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:54:24 +0900 Subject: [PATCH 5/5] fix(form): handle signed over-limit amounts --- lib/currency.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/currency.ts b/lib/currency.ts index f701bc9..23110ce 100644 --- a/lib/currency.ts +++ b/lib/currency.ts @@ -44,7 +44,7 @@ export function isAmountInputAboveMax(input: string): boolean { if (!Number.isFinite(value) || value < 0) return false; if (value > MAX_AMOUNT) return true; - const decimalMatch = normalized.match(/^(\d+)(?:\.(\d*))?$/); + const decimalMatch = normalized.match(/^\+?(\d+)(?:\.(\d*))?$/); if (!decimalMatch) return false; const integerPart = decimalMatch[1].replace(/^0+(?=\d)/, '');