diff --git a/package-lock.json b/package-lock.json index 7bd979d..e862d81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,12 +14,12 @@ "@stellar/stellar-sdk": "^12.1.0", "buffer": "^6.0.3", "expo": "~52.0.46", - "expo-asset": "^56.0.17", + "expo-asset": "~11.0.5", "expo-camera": "~16.0.0", "expo-clipboard": "~7.0.1", "expo-constants": "~17.0.8", - "expo-secure-store": "^56.0.4", - "expo-sharing": "^56.0.18", + "expo-secure-store": "~14.0.1", + "expo-sharing": "~13.0.1", "expo-status-bar": "~2.0.1", "react": "18.3.1", "react-native": "0.76.9", @@ -2882,25 +2882,6 @@ "node": ">= 10.0.0" } }, - "node_modules/@expo/require-utils": { - "version": "56.1.6", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.6.tgz", - "integrity": "sha512-e68r6X+c7yPxJP0gac/CaJc/RCHeLB2Bf4MyCuqfNM4YXfl1ONmQbQlRL0RbwBfEHn6A6UZKj+5UX9j0Im646g==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-modules-commonjs": "^7.24.8" - }, - "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@expo/rudder-sdk-node": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@expo/rudder-sdk-node/-/rudder-sdk-node-1.1.1.tgz", @@ -6880,13 +6861,15 @@ } }, "node_modules/expo-asset": { - "version": "56.0.21", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.21.tgz", - "integrity": "sha512-0p5JByDPy6MxB1S2TBoz38r6I8IYx6z5uD/e00nc0sc9f8xRpcZPCJALbOsGW8PjOwXNfTyEE9KsC6xTLjC2ZA==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-11.0.5.tgz", + "integrity": "sha512-TL60LmMBGVzs3NQcO8ylWqBumMh4sx0lmeJsn7+9C88fylGDhyyVnKZ1PyTXo9CVDBkndutZx2JUEQWM9BaiXw==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.10.4", - "expo-constants": "~56.0.22" + "@expo/image-utils": "^0.6.5", + "expo-constants": "~17.0.8", + "invariant": "^2.2.4", + "md5-file": "^3.2.3" }, "peerDependencies": { "expo": "*", @@ -6894,69 +6877,6 @@ "react-native": "*" } }, - "node_modules/expo-asset/node_modules/@expo/env": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.1.tgz", - "integrity": "sha512-JvBdZa5OkTd+dGEtt3fLW9OF6RlIp9SNu9VyMSiWPV5szMDTSAYbX8Uj3cRvZcU1zzaRbqnTSsHk2AE8WXHfxQ==", - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "getenv": "^2.0.0" - }, - "engines": { - "node": ">=20.12.0" - } - }, - "node_modules/expo-asset/node_modules/@expo/image-utils": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.4.tgz", - "integrity": "sha512-QGdC9eeNyF7vozMV77wQQwX4z3ZJK8D707WSTcBTE/XI19T41g5NtGVM/BMqb1oBV+zSZlNwpOA6bXbbvtC2UA==", - "license": "MIT", - "dependencies": { - "@expo/require-utils": "^56.1.6", - "@expo/spawn-async": "^1.8.0", - "chalk": "^4.0.0", - "getenv": "^2.0.0", - "jimp-compact": "0.16.1", - "parse-png": "^2.1.0", - "semver": "^7.6.0" - } - }, - "node_modules/expo-asset/node_modules/expo-constants": { - "version": "56.0.22", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.22.tgz", - "integrity": "sha512-XNRssTBSGDR3gsaYhhp513WdpPsLi89wgfYT0wLsCtMY8vXAgXR4jNlaYGXFCnvNVbx9gEDSBHOSntqRK1i8Lw==", - "license": "MIT", - "dependencies": { - "@expo/env": "~2.3.1" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, - "node_modules/expo-asset/node_modules/getenv": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", - "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/expo-asset/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/expo-camera": { "version": "16.0.18", "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-16.0.18.tgz", @@ -7067,193 +6987,21 @@ } }, "node_modules/expo-secure-store": { - "version": "56.0.4", - "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-56.0.4.tgz", - "integrity": "sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-14.0.1.tgz", + "integrity": "sha512-QUS+j4+UG4jRQalgnpmTvvrFnMVLqPiUZRzYPnG3+JrZ5kwVW2w6YS3WWerPoR7C6g3y/a2htRxRSylsDs+TaQ==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-sharing": { - "version": "56.0.23", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.23.tgz", - "integrity": "sha512-O/f6+ttCuwwTDnzM+ZqeVeNOJJ3lY1qvhoMfYeYAgEFi36qms5j+uuUISm520wmrG+1AwT7Is5tdSRk4zf+0AA==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-13.0.1.tgz", + "integrity": "sha512-qych3Nw65wlFcnzE/gRrsdtvmdV0uF4U4qVMZBJYPG90vYyWh2QM9rp1gVu0KWOBc7N8CC2dSVYn4/BXqJy6Xw==", "license": "MIT", - "dependencies": { - "@expo/config-plugins": "^56.0.14", - "@expo/config-types": "^56.0.7", - "@expo/plist": "^0.7.0" - }, "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo-sharing/node_modules/@expo/config-plugins": { - "version": "56.0.14", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.14.tgz", - "integrity": "sha512-pdjBwH67BF1dnim4N/OxgBshhil27eu3C9uuhwzzK2Bnzl6bIcdt9WjEVuMXluFnEpWe0ws5sxE8NvBR5Ms7bQ==", - "license": "MIT", - "dependencies": { - "@expo/config-types": "^56.0.7", - "@expo/json-file": "~10.2.0", - "@expo/plist": "^0.7.0", - "@expo/require-utils": "^56.1.6", - "@expo/sdk-runtime-versions": "^1.0.0", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "semver": "^7.5.4", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "node_modules/expo-sharing/node_modules/@expo/config-types": { - "version": "56.0.7", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.7.tgz", - "integrity": "sha512-V7bxawNsNned/yMppAHdisIOxniZXgPKRWpIUiQOQBs45/A5MBd2gfDM4Ecq5gnbilnQUTaI6Zxn6JcW7L3TAA==", - "license": "MIT" - }, - "node_modules/expo-sharing/node_modules/@expo/json-file": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", - "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, - "node_modules/expo-sharing/node_modules/@expo/plist": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", - "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.5.1", - "xmlbuilder": "^15.1.1" - } - }, - "node_modules/expo-sharing/node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/expo-sharing/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/expo-sharing/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/expo-sharing/node_modules/getenv": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", - "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/expo-sharing/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/expo-sharing/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/expo-sharing/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/expo-sharing/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/expo-sharing/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/expo-sharing/node_modules/xmlbuilder": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", - "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", - "license": "MIT", - "engines": { - "node": ">=8.0" + "expo": "*" } }, "node_modules/expo-status-bar": { @@ -7266,23 +7014,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-asset": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-11.0.5.tgz", - "integrity": "sha512-TL60LmMBGVzs3NQcO8ylWqBumMh4sx0lmeJsn7+9C88fylGDhyyVnKZ1PyTXo9CVDBkndutZx2JUEQWM9BaiXw==", - "license": "MIT", - "dependencies": { - "@expo/image-utils": "^0.6.5", - "expo-constants": "~17.0.8", - "invariant": "^2.2.4", - "md5-file": "^3.2.3" - }, - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-file-system": { "version": "18.0.12", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz", @@ -14012,7 +13743,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index e8729bc..2f0d08b 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,12 @@ "@stellar/stellar-sdk": "^12.1.0", "buffer": "^6.0.3", "expo": "~52.0.46", - "expo-asset": "^56.0.17", + "expo-asset": "~11.0.5", "expo-camera": "~16.0.0", "expo-clipboard": "~7.0.1", "expo-constants": "~17.0.8", - "expo-secure-store": "^56.0.4", - "expo-sharing": "^56.0.18", + "expo-secure-store": "~14.0.1", + "expo-sharing": "~13.0.1", "expo-status-bar": "~2.0.1", "react": "18.3.1", "react-native": "0.76.9", @@ -42,5 +42,12 @@ "react-test-renderer": "^18.3.1", "ts-jest": "^29.4.12", "typescript": "~5.3.3" + }, + "expo": { + "doctor": { + "appConfigFieldsNotSyncedCheck": { + "enabled": false + } + } } } diff --git a/src/api/apiSettings.test.ts b/src/api/apiSettings.test.ts new file mode 100644 index 0000000..b200353 --- /dev/null +++ b/src/api/apiSettings.test.ts @@ -0,0 +1,43 @@ +import { validateApiBaseUrl } from "./apiSettings"; + +describe("validateApiBaseUrl", () => { + it("accepts https URLs for normal API hosts", () => { + expect(validateApiBaseUrl("https://api.example.com").isValid).toBe(true); + expect(validateApiBaseUrl(" https://api.example.com/v1 ").isValid).toBe(true); + }); + + it("accepts localhost and loopback development URLs over http", () => { + expect(validateApiBaseUrl("http://localhost:4021").isValid).toBe(true); + expect(validateApiBaseUrl("http://dev.localhost:4021").isValid).toBe(true); + expect(validateApiBaseUrl("http://127.0.0.1:4021").isValid).toBe(true); + expect(validateApiBaseUrl("http://[::1]:4021").isValid).toBe(true); + }); + + it("accepts private LAN IP development URLs over http", () => { + expect(validateApiBaseUrl("http://10.0.2.2:4021").isValid).toBe(true); + expect(validateApiBaseUrl("http://172.16.0.2:4021").isValid).toBe(true); + expect(validateApiBaseUrl("http://172.31.255.255:4021").isValid).toBe(true); + expect(validateApiBaseUrl("http://192.168.1.20:4021").isValid).toBe(true); + }); + + it("rejects empty and incomplete URLs", () => { + expect(validateApiBaseUrl("").isValid).toBe(false); + expect(validateApiBaseUrl("api.example.com").isValid).toBe(false); + }); + + it("rejects unsupported protocols", () => { + expect(validateApiBaseUrl("ftp://api.example.com").isValid).toBe(false); + }); + + it("rejects insecure http for public hosts", () => { + expect(validateApiBaseUrl("http://api.example.com").isValid).toBe(false); + expect(validateApiBaseUrl("http://8.8.8.8:4021").isValid).toBe(false); + expect(validateApiBaseUrl("http://172.32.0.1:4021").isValid).toBe(false); + }); + + it("rejects credentials, query parameters, and fragments", () => { + expect(validateApiBaseUrl("https://user:pass@api.example.com").isValid).toBe(false); + expect(validateApiBaseUrl("https://api.example.com?debug=true").isValid).toBe(false); + expect(validateApiBaseUrl("https://api.example.com#resources").isValid).toBe(false); + }); +}); diff --git a/src/api/apiSettings.ts b/src/api/apiSettings.ts index b9d65ac..663e739 100644 --- a/src/api/apiSettings.ts +++ b/src/api/apiSettings.ts @@ -5,10 +5,92 @@ const STORAGE_KEY = "mindvault_api_base_url"; const DEFAULT_API_BASE_URL = (Constants.expoConfig?.extra?.apiUrl as string | undefined) ?? "http://localhost:4021"; +export interface ApiBaseUrlValidationResult { + isValid: boolean; + errorMessage?: string; +} + export function getDefaultApiBaseUrl(): string { return DEFAULT_API_BASE_URL; } +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + return ( + normalized === "localhost" || + normalized.endsWith(".localhost") || + normalized === "127.0.0.1" || + normalized === "[::1]" + ); +} + +function isPrivateLanIp(hostname: string): boolean { + const parts = hostname.split(".").map((part) => Number(part)); + if ( + parts.length !== 4 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return false; + } + + const [first, second] = parts; + return ( + first === 10 || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) + ); +} + +export function validateApiBaseUrl(value: string): ApiBaseUrlValidationResult { + const trimmed = value.trim(); + if (!trimmed) { + return { isValid: false, errorMessage: "API base URL cannot be empty." }; + } + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return { + isValid: false, + errorMessage: "Enter a complete URL, including http:// or https://.", + }; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { + isValid: false, + errorMessage: "API base URL must use http:// or https://.", + }; + } + + if (url.username || url.password) { + return { + isValid: false, + errorMessage: "API base URL cannot include a username or password.", + }; + } + + if (url.search || url.hash) { + return { + isValid: false, + errorMessage: "API base URL cannot include query parameters or fragments.", + }; + } + + const allowsInsecureHttp = + isLoopbackHost(url.hostname) || isPrivateLanIp(url.hostname); + if (url.protocol === "http:" && !allowsInsecureHttp) { + return { + isValid: false, + errorMessage: + "Use https:// unless connecting to localhost or a private LAN IP.", + }; + } + + return { isValid: true }; +} + export async function loadApiBaseUrl(): Promise { const saved = await AsyncStorage.getItem(STORAGE_KEY); return saved && saved.trim().length > 0 ? saved : DEFAULT_API_BASE_URL; @@ -21,5 +103,10 @@ export async function saveApiBaseUrl(value: string): Promise { return; } + const validation = validateApiBaseUrl(trimmed); + if (!validation.isValid) { + throw new Error(validation.errorMessage ?? "Invalid API base URL."); + } + await AsyncStorage.setItem(STORAGE_KEY, trimmed); } diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index e493ca2..aefff06 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -18,6 +18,7 @@ import { getDefaultApiBaseUrl, loadApiBaseUrl, saveApiBaseUrl, + validateApiBaseUrl, } from "../api/apiSettings"; import { initializeApiBaseUrl } from "../api/resources"; @@ -42,10 +43,15 @@ export function SettingsScreen() { const handleSave = async () => { const trimmed = apiUrl.trim(); - if (!trimmed) { - Alert.alert("Invalid URL", "API base URL cannot be empty."); + const validation = validateApiBaseUrl(trimmed); + if (!validation.isValid) { + Alert.alert( + "Invalid URL", + validation.errorMessage ?? "Invalid API base URL." + ); return; } + setIsSaving(true); try { await saveApiBaseUrl(trimmed);