From 0f113ffb4e8e4863f4800a1125abdf0aee263dc3 Mon Sep 17 00:00:00 2001 From: kvmgithub Date: Wed, 8 Jul 2026 12:19:53 +0200 Subject: [PATCH] fix: remove credential logging and dead OAuth code Registration requests logged the authorization code and PKCE verifier to stderr, and license decryption logged the audiobook AES key; both are readable via logcat on Android. Error messages no longer embed full API response bodies, the unused OAuthWebView component (Math.random serial generation, mixedContentMode=always) is deleted, and the registration test fixture uses synthetic account data. Refs Promises/LibriSync#22 --- .gitignore | 1 + native/rust-core/src/api/auth.rs | 10 +- native/rust-core/src/api/client.rs | 19 +- native/rust-core/src/api/license.rs | 15 +- native/rust-core/src/api/registration.rs | 28 +- .../test_fixtures/registration_response.json | 14 +- .../rust-core/tests/integration_oauth_test.rs | 22 +- src/components/OAuthWebView.tsx | 280 ------------------ 8 files changed, 46 insertions(+), 343 deletions(-) delete mode 100644 src/components/OAuthWebView.tsx diff --git a/.gitignore b/.gitignore index 3811f4e..7485a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,4 @@ CLAUDE.md build-output/ *.apk !android/app/build/outputs/apk/ +.claude/ diff --git a/native/rust-core/src/api/auth.rs b/native/rust-core/src/api/auth.rs index 4e0ecf9..a1fcc24 100644 --- a/native/rust-core/src/api/auth.rs +++ b/native/rust-core/src/api/auth.rs @@ -1402,14 +1402,8 @@ pub async fn exchange_authorization_code( "requested_extensions": ["device_info", "customer_info"] }); - // Log the request for debugging - eprintln!("=== Device Registration Request ==="); - eprintln!("URL: {}", register_url); - eprintln!( - "Body: {}", - serde_json::to_string_pretty(&request_body).unwrap_or_default() - ); - eprintln!("==================================="); + // Never log the request body: it contains the authorization code and + // PKCE verifier, and stderr is readable via logcat on Android. // Make HTTP request let client = reqwest::Client::new(); diff --git a/native/rust-core/src/api/client.rs b/native/rust-core/src/api/client.rs index b2b7c60..fc35b69 100644 --- a/native/rust-core/src/api/client.rs +++ b/native/rust-core/src/api/client.rs @@ -706,17 +706,11 @@ impl AudibleClient { match serde_json::from_str::(&response_text) { Ok(data) => Ok(data), Err(e) => { - // Extract context around the error location (800 chars) - let error_col = e.column(); - let start = error_col.saturating_sub(400); - let end = (error_col + 400).min(response_text.len()); - let context = &response_text[start..end]; - + // Do not echo the response body into the error message; a + // partial API response can contain token or account data. Err(LibationError::InvalidApiResponse { - message: format!( - "Parse error: {} at col {}. Context: ...{}...", - e, error_col, context - ), + message: format!("Parse error: {} at line {} col {}", e, e.line(), e.column()), + // Body kept out of the message; this field is not displayed. response_body: Some(response_text), }) } @@ -729,8 +723,11 @@ impl AudibleClient { let url = response.url().clone(); let error_body = response.text().await.unwrap_or_default(); + // Cap the reflected body: error responses can echo request data. + let snippet: String = error_body.chars().take(300).collect(); + Err(LibationError::api_failed( - format!("API request failed: {}", error_body), + format!("API request failed: {}", snippet), Some(status.as_u16()), Some(self.extract_endpoint_from_url(url.as_str())), )) diff --git a/native/rust-core/src/api/license.rs b/native/rust-core/src/api/license.rs index f61291c..a068e02 100644 --- a/native/rust-core/src/api/license.rs +++ b/native/rust-core/src/api/license.rs @@ -418,24 +418,15 @@ impl KeyData { LibationError::InvalidInput(format!("Decrypted license is not valid UTF-8: {}", e)) })?; - // Debug: print decrypted JSON - eprintln!("🔍 DEBUG: Decrypted voucher JSON:\n{}\n", json_str); + // Never log json_str: it contains the AES key/iv for the audiobook. // Parse JSON to get Voucher // Reference: ContentLicenseDtoV10.cs:46 - VoucherDtoV10.FromJson(plainText) let voucher: Voucher = serde_json::from_str(&json_str).map_err(|e| { - LibationError::InvalidInput(format!( - "Failed to parse decrypted voucher JSON: {}\nJSON was: {}", - e, json_str - )) + // Do not include json_str in the error: it contains the key/iv. + LibationError::InvalidInput(format!("Failed to parse decrypted voucher JSON: {}", e)) })?; - eprintln!( - "🔍 DEBUG: Voucher key length: {}, iv length: {:?}", - voucher.key.len(), - voucher.iv.as_ref().map(|s| s.len()) - ); - // Convert voucher to KeyData // Check if key is hex (32 chars) or base64 (24 chars) if voucher.key.len() == 32 { diff --git a/native/rust-core/src/api/registration.rs b/native/rust-core/src/api/registration.rs index 70fc1ad..aa94e0a 100644 --- a/native/rust-core/src/api/registration.rs +++ b/native/rust-core/src/api/registration.rs @@ -252,7 +252,7 @@ mod tests { // Verify basic structure assert_eq!(response.request_id, "8e570a18-8232-4df0-a212-c0d21860fcd5"); - assert_eq!(response.response.success.customer_id, "amzn1.account.AGMGLSGIFYVALF2MEO4F3JJQRLSA"); + assert_eq!(response.response.success.customer_id, "amzn1.account.TESTFIXTUREACCOUNT00000000000"); } #[test] @@ -309,8 +309,8 @@ mod tests { let response = RegistrationResponse::from_json(TEST_FIXTURE).unwrap(); let device_info = &response.response.success.extensions.device_info; - assert_eq!(device_info.device_name, "Henning's 7th Android"); - assert_eq!(device_info.device_serial_number, "B45EF975C33A7B7E8DAF4D96E39B8040"); + assert_eq!(device_info.device_name, "Test Fixture Android"); + assert_eq!(device_info.device_serial_number, "00000000000000000000000000000000"); assert_eq!(device_info.device_type, "A10KISP2GWF0E4"); } @@ -320,10 +320,10 @@ mod tests { let customer_info = &response.response.success.extensions.customer_info; assert_eq!(customer_info.account_pool, "Amazon"); - assert_eq!(customer_info.user_id, "amzn1.account.AGMGLSGIFYVALF2MEO4F3JJQRLSA"); + assert_eq!(customer_info.user_id, "amzn1.account.TESTFIXTUREACCOUNT00000000000"); assert_eq!(customer_info.home_region, "NA"); - assert_eq!(customer_info.name, "Henning Berge"); - assert_eq!(customer_info.given_name, "Henning"); + assert_eq!(customer_info.name, "Test User"); + assert_eq!(customer_info.given_name, "Test"); } #[test] @@ -344,13 +344,13 @@ mod tests { assert!(data.adp_token.contains("{enc:")); // Verify device info - assert_eq!(data.device_serial_number, "B45EF975C33A7B7E8DAF4D96E39B8040"); + assert_eq!(data.device_serial_number, "00000000000000000000000000000000"); assert_eq!(data.device_type, "A10KISP2GWF0E4"); - assert_eq!(data.device_name, "Henning's 7th Android"); + assert_eq!(data.device_name, "Test Fixture Android"); // Verify customer info - assert_eq!(data.amazon_account_id, "amzn1.account.AGMGLSGIFYVALF2MEO4F3JJQRLSA"); - assert_eq!(data.customer_info.name, "Henning Berge"); + assert_eq!(data.amazon_account_id, "amzn1.account.TESTFIXTUREACCOUNT00000000000"); + assert_eq!(data.customer_info.name, "Test User"); assert_eq!(data.customer_info.home_region, "NA"); // Verify cookies @@ -370,11 +370,11 @@ mod tests { // Verify Identity fields assert!(identity.access_token.token.starts_with("Atna|")); assert!(identity.refresh_token.starts_with("Atnr|")); - assert_eq!(identity.device_serial_number, "B45EF975C33A7B7E8DAF4D96E39B8040"); + assert_eq!(identity.device_serial_number, "00000000000000000000000000000000"); assert_eq!(identity.device_type, "A10KISP2GWF0E4"); - assert_eq!(identity.amazon_account_id, "amzn1.account.AGMGLSGIFYVALF2MEO4F3JJQRLSA"); + assert_eq!(identity.amazon_account_id, "amzn1.account.TESTFIXTUREACCOUNT00000000000"); assert_eq!(identity.locale, locale); - assert_eq!(identity.customer_info.name, "Henning Berge"); + assert_eq!(identity.customer_info.name, "Test User"); } #[test] @@ -434,6 +434,6 @@ mod tests { // These should be the same assert_eq!(customer_id, user_id); - assert_eq!(customer_id, "amzn1.account.AGMGLSGIFYVALF2MEO4F3JJQRLSA"); + assert_eq!(customer_id, "amzn1.account.TESTFIXTUREACCOUNT00000000000"); } } diff --git a/native/rust-core/test_fixtures/registration_response.json b/native/rust-core/test_fixtures/registration_response.json index a007fae..6fa6369 100644 --- a/native/rust-core/test_fixtures/registration_response.json +++ b/native/rust-core/test_fixtures/registration_response.json @@ -2,7 +2,7 @@ "request_id": "8e570a18-8232-4df0-a212-c0d21860fcd5", "response": { "success": { - "customer_id": "amzn1.account.AGMGLSGIFYVALF2MEO4F3JJQRLSA", + "customer_id": "amzn1.account.TESTFIXTUREACCOUNT00000000000", "tokens": { "bearer": { "access_token": "Atna|synthetic-access-token-not-valid-for-api-use-000000000000000000000000000000000000000000000000000000000000", @@ -11,7 +11,7 @@ }, "mac_dms": { "device_private_key": "MIIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "adp_token": "{enc:synthetic-encrypted-payload}{key:synthetic-key}{iv:synthetic-iv}{name:Henning's 7th Android}{serial:B45EF975C33A7B7E8DAF4D96E39B8040}" + "adp_token": "{enc:synthetic-encrypted-payload}{key:synthetic-key}{iv:synthetic-iv}{name:Test Fixture Android}{serial:00000000000000000000000000000000}" }, "website_cookies": [ { @@ -67,16 +67,16 @@ }, "extensions": { "device_info": { - "device_name": "Henning's 7th Android", - "device_serial_number": "B45EF975C33A7B7E8DAF4D96E39B8040", + "device_name": "Test Fixture Android", + "device_serial_number": "00000000000000000000000000000000", "device_type": "A10KISP2GWF0E4" }, "customer_info": { "account_pool": "Amazon", - "user_id": "amzn1.account.AGMGLSGIFYVALF2MEO4F3JJQRLSA", + "user_id": "amzn1.account.TESTFIXTUREACCOUNT00000000000", "home_region": "NA", - "name": "Henning Berge", - "given_name": "Henning" + "name": "Test User", + "given_name": "Test" } } } diff --git a/native/rust-core/tests/integration_oauth_test.rs b/native/rust-core/tests/integration_oauth_test.rs index be10dc6..bc09e31 100644 --- a/native/rust-core/tests/integration_oauth_test.rs +++ b/native/rust-core/tests/integration_oauth_test.rs @@ -25,7 +25,7 @@ fn test_parse_real_registration_response() { assert_eq!(response.request_id, "8e570a18-8232-4df0-a212-c0d21860fcd5"); assert_eq!( response.response.success.customer_id, - "amzn1.account.AGMGLSGIFYVALF2MEO4F3JJQRLSA" + "amzn1.account.TESTFIXTUREACCOUNT00000000000" ); println!("✅ Registration response parsed successfully"); @@ -51,13 +51,13 @@ fn test_extract_all_tokens() { assert!(data.adp_token.contains("{key:")); // Device info - assert_eq!(data.device_serial_number, "B45EF975C33A7B7E8DAF4D96E39B8040"); + assert_eq!(data.device_serial_number, "00000000000000000000000000000000"); assert_eq!(data.device_type, "A10KISP2GWF0E4"); - assert_eq!(data.device_name, "Henning's 7th Android"); + assert_eq!(data.device_name, "Test Fixture Android"); // Customer info - assert_eq!(data.customer_info.name, "Henning Berge"); - assert_eq!(data.customer_info.given_name, "Henning"); + assert_eq!(data.customer_info.name, "Test User"); + assert_eq!(data.customer_info.given_name, "Test"); assert_eq!(data.customer_info.home_region, "NA"); // Cookies @@ -84,9 +84,9 @@ fn test_create_identity_from_real_data() { // Verify Identity has all required fields assert!(identity.access_token.token.len() > 50); assert!(identity.refresh_token.len() > 50); - assert_eq!(identity.device_serial_number, "B45EF975C33A7B7E8DAF4D96E39B8040"); + assert_eq!(identity.device_serial_number, "00000000000000000000000000000000"); assert_eq!(identity.device_type, "A10KISP2GWF0E4"); - assert_eq!(identity.amazon_account_id, "amzn1.account.AGMGLSGIFYVALF2MEO4F3JJQRLSA"); + assert_eq!(identity.amazon_account_id, "amzn1.account.TESTFIXTUREACCOUNT00000000000"); assert_eq!(identity.locale, locale); // Verify not expired @@ -117,7 +117,7 @@ fn test_create_account_with_real_identity() { // Verify account assert_eq!(account.account_id, account_id); - assert_eq!(account.account_name, "Henning Berge"); + assert_eq!(account.account_name, "Test User"); assert!(account.identity.is_some()); assert!(!account.needs_token_refresh()); @@ -222,10 +222,10 @@ fn test_customer_info_complete() { // Verify all fields assert_eq!(customer_info.account_pool, "Amazon"); - assert_eq!(customer_info.user_id, "amzn1.account.AGMGLSGIFYVALF2MEO4F3JJQRLSA"); + assert_eq!(customer_info.user_id, "amzn1.account.TESTFIXTUREACCOUNT00000000000"); assert_eq!(customer_info.home_region, "NA"); - assert_eq!(customer_info.name, "Henning Berge"); - assert_eq!(customer_info.given_name, "Henning"); + assert_eq!(customer_info.name, "Test User"); + assert_eq!(customer_info.given_name, "Test"); // Verify customer_id matches user_id assert_eq!( diff --git a/src/components/OAuthWebView.tsx b/src/components/OAuthWebView.tsx deleted file mode 100644 index cd500a2..0000000 --- a/src/components/OAuthWebView.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import React, { useRef, useState, useEffect } from 'react'; -import { Modal, View, Text, ActivityIndicator, TouchableOpacity, Alert } from 'react-native'; -import { WebView, WebViewNavigation } from 'react-native-webview'; -import { useStyles } from '../hooks/useStyles'; -import { useTheme } from '../styles/theme'; -import type { Theme } from '../hooks/useStyles'; -import type { TokenResponse, OAuthFlowData } from '../types/auth'; - -/** - * Props for OAuthWebView component - */ -interface OAuthWebViewProps { - visible: boolean; - localeCode: string; - onSuccess: (tokens: TokenResponse, deviceSerial: string) => void; - onCancel: () => void; - onError: (error: Error) => void; -} - -/** - * WebView-based OAuth authentication component for Audible - * - * This component handles the OAuth 2.0 + PKCE authentication flow: - * 1. Generates OAuth URL with PKCE challenge - * 2. Opens Amazon login in WebView - * 3. Monitors for callback URL with authorization code - * 4. Exchanges authorization code for access/refresh tokens - * 5. Returns tokens to parent component via onSuccess callback - */ -export default function OAuthWebView({ - visible, - localeCode, - onSuccess, - onCancel, - onError -}: OAuthWebViewProps) { - const styles = useStyles(createStyles); - const { colors } = useTheme(); - const [loading, setLoading] = useState(true); - const [authUrl, setAuthUrl] = useState(''); - const [flowData, setFlowData] = useState(null); - const webViewRef = useRef(null); - - /** - * Initialize OAuth flow when component becomes visible - * Generates device serial and fetches OAuth URL from Rust core - */ - useEffect(() => { - if (visible) { - initializeOAuth(); - } - }, [visible, localeCode]); - - /** - * Initialize OAuth flow by generating device serial and OAuth URL - */ - const initializeOAuth = async () => { - try { - setLoading(true); - - // Import Rust bridge - const { initiateOAuth } = require('../../modules/expo-rust-bridge'); - - // Initiate OAuth flow (generates device serial internally) - const oauthData = initiateOAuth(localeCode); - - setAuthUrl(oauthData.url); - setFlowData({ - deviceSerial: oauthData.deviceSerial, - pkceVerifier: oauthData.pkceVerifier, - localeCode - }); - - setLoading(false); - } catch (error) { - console.error('OAuth initialization error:', error); - onError(error as Error); - } - }; - - /** - * Handle WebView navigation state changes - * Monitors for callback URL and extracts authorization code - */ - const handleNavigationStateChange = async (navState: WebViewNavigation) => { - // Check if we've reached the callback URL - if (navState.url.includes('/ap/maplanding') || navState.url.includes('openid.oa2.authorization_code=')) { - setLoading(true); - - try { - if (!flowData) { - throw new Error('OAuth flow data not initialized'); - } - - // Import Rust bridge - const { completeOAuthFlow } = require('../../modules/expo-rust-bridge'); - - // Complete OAuth flow (parse callback, exchange code for tokens) - const tokenResponse = await completeOAuthFlow( - navState.url, - flowData.localeCode, - flowData.deviceSerial, - flowData.pkceVerifier - ); - - // Pass tokens and device serial to parent - onSuccess(tokenResponse, flowData.deviceSerial); - - } catch (error) { - console.error('OAuth callback error:', error); - onError(error as Error); - } finally { - setLoading(false); - } - } - }; - - return ( - - - {/* Header */} - - Sign in to Audible - - Cancel - - - - {/* WebView */} - {loading && !authUrl ? ( - - - Initializing authentication... - - ) : ( - <> - {loading && ( - - - - )} - setLoading(true)} - onLoadEnd={() => setLoading(false)} - onError={(syntheticEvent) => { - const { nativeEvent } = syntheticEvent; - console.error('WebView error:', nativeEvent); - onError(new Error(`WebView error: ${nativeEvent.description}`)); - }} - style={styles.webview} - // Enable JavaScript (required for Amazon login) - javaScriptEnabled={true} - // Enable DOM storage (required for some login flows) - domStorageEnabled={true} - // Start loading immediately - startInLoadingState={true} - // Allow third-party cookies (required for OAuth) - thirdPartyCookiesEnabled={true} - // iOS specific: Allow inline media playback - allowsInlineMediaPlayback={true} - // Android specific: Mixed content mode - mixedContentMode="always" - /> - - )} - - - ); -} - -/** - * Generate a random device serial (32 hex characters) - * This simulates a unique device identifier for Audible API - */ -function generateDeviceSerial(): string { - const bytes = new Uint8Array(16); - for (let i = 0; i < 16; i++) { - bytes[i] = Math.floor(Math.random() * 256); - } - return Array.from(bytes) - .map(b => b.toString(16).padStart(2, '0').toUpperCase()) - .join(''); -} - -/** - * Generate a random string of specified length - * Used for PKCE verifier generation - */ -function generateRandomString(length: number): string { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; - let result = ''; - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; -} - -/** - * Generate a mock OAuth URL for testing - * TODO: Replace with actual Rust bridge call - */ -function generateMockOAuthUrl(localeCode: string): string { - const domains: Record = { - us: 'amazon.com', - uk: 'amazon.co.uk', - de: 'amazon.de', - fr: 'amazon.fr', - ca: 'amazon.ca', - au: 'amazon.com.au', - it: 'amazon.it', - es: 'amazon.es', - in: 'amazon.in', - jp: 'amazon.co.jp', - }; - - const domain = domains[localeCode] || 'amazon.com'; - - // This is a mock URL - in production, this would be generated by Rust - return `https://www.${domain}/ap/signin?openid.return_to=https://www.${domain}/ap/maplanding`; -} - -const createStyles = (theme: Theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.background, - }, - header: { - flexDirection: 'row' as const, - justifyContent: 'space-between' as const, - alignItems: 'center' as const, - padding: theme.spacing.md, - paddingTop: 60, // Account for status bar - backgroundColor: theme.colors.backgroundSecondary, - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - }, - headerTitle: { - ...theme.typography.subtitle, - }, - cancelButton: { - padding: theme.spacing.sm, - }, - cancelButtonText: { - ...theme.typography.body, - color: theme.colors.accent, - fontWeight: '600' as const, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center' as const, - alignItems: 'center' as const, - gap: theme.spacing.md, - }, - loadingOverlay: { - position: 'absolute' as const, - top: 0, - left: 0, - right: 0, - bottom: 0, - justifyContent: 'center' as const, - alignItems: 'center' as const, - backgroundColor: `${theme.colors.background}CC`, // 80% opacity - zIndex: 1, - }, - loadingText: { - ...theme.typography.caption, - }, - webview: { - flex: 1, - backgroundColor: theme.colors.background, - }, -});