diff --git a/TEST/testGooglePhotosAPI.js b/TEST/testGooglePhotosAPI.js index 3a4074a..8dba120 100644 --- a/TEST/testGooglePhotosAPI.js +++ b/TEST/testGooglePhotosAPI.js @@ -42,9 +42,12 @@ async function main() { console.log('[1] Fetching user info...'); try { const res = await fetch(USERINFO_URL, { headers }); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); - const user = await res.json(); - console.log(` Authenticated as: ${user.email} (${user.name})\n`); + if (!res.ok) { + console.log(` Failed: HTTP ${res.status}: ${await res.text()}\n`); + } else { + const user = await res.json(); + console.log(` Authenticated as: ${user.email} (${user.name})\n`); + } } catch (err) { console.log(` Failed: ${err.message}\n`); } @@ -57,16 +60,19 @@ async function main() { headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ pageSize: 10 }), }); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); - const data = await res.json(); - const items = data.mediaItems || []; - console.log(` Found ${items.length} items`); - items.slice(0, 5).forEach((item, i) => { - console.log(` ${i + 1}. ${item.filename} (${item.mimeType})`); - }); - if (items.length > 5) console.log(` ... and ${items.length - 5} more`); - if (data.nextPageToken) console.log(' More pages available'); - console.log(); + if (!res.ok) { + console.log(` Failed: HTTP ${res.status}: ${await res.text()}\n`); + } else { + const data = await res.json(); + const items = data.mediaItems || []; + console.log(` Found ${items.length} items`); + items.slice(0, 5).forEach((item, i) => { + console.log(` ${i + 1}. ${item.filename} (${item.mimeType})`); + }); + if (items.length > 5) console.log(` ... and ${items.length - 5} more`); + if (data.nextPageToken) console.log(' More pages available'); + console.log(); + } } catch (err) { console.log(` Failed: ${err.message}\n`); } @@ -75,14 +81,17 @@ async function main() { console.log('[3] Listing albums...'); try { const res = await fetch(`${API_BASE}/albums?pageSize=10`, { headers }); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); - const data = await res.json(); - const albums = data.albums || []; - console.log(` Found ${albums.length} albums`); - albums.slice(0, 5).forEach((album, i) => { - console.log(` ${i + 1}. "${album.title}" - ${album.mediaItemsCount || 0} items`); - }); - console.log(); + if (!res.ok) { + console.log(` Failed: HTTP ${res.status}: ${await res.text()}\n`); + } else { + const data = await res.json(); + const albums = data.albums || []; + console.log(` Found ${albums.length} albums`); + albums.slice(0, 5).forEach((album, i) => { + console.log(` ${i + 1}. "${album.title}" - ${album.mediaItemsCount || 0} items`); + }); + console.log(); + } } catch (err) { console.log(` Failed: ${err.message}\n`); } diff --git a/src/AUTH/GoogleAUTH.js b/src/AUTH/GoogleAUTH.js index 1cb760d..029ba2b 100644 --- a/src/AUTH/GoogleAUTH.js +++ b/src/AUTH/GoogleAUTH.js @@ -2,6 +2,7 @@ import * as AuthSession from 'expo-auth-session'; import * as WebBrowser from 'expo-web-browser'; import * as Application from 'expo-application'; import { AuthServiceBase } from './AuthServiceBase'; +import { logError } from '../services/ErrorHandler'; WebBrowser.maybeCompleteAuthSession(); @@ -24,7 +25,6 @@ const SCOPES = [ 'https://www.googleapis.com/auth/photoslibrary', ]; - // Redirect URI uses the Android package name as scheme, matching what // expo-auth-session/providers/google generates for installed Android apps. // Result: com.bobthebob.massphotoapp:/oauthredirect @@ -37,110 +37,85 @@ class GoogleAUTH extends AuthServiceBase { this.refreshToken = null; this.expiresAt = null; this.email = null; - this.idToken = null; } async authenticate() { - try { - if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') { - console.warn('Google Auth: no Android client ID — using placeholder mode'); - this.accessToken = 'placeholder_google_token_' + Date.now(); - this.refreshToken = 'placeholder_refresh_' + Date.now(); - this.expiresAt = Date.now() + 3600000; - this.email = 'google-user@placeholder.com'; - return { - success: true, - accessToken: this.accessToken, - refreshToken: this.refreshToken, - email: this.email, - }; - } - - console.log('OAuth redirect URI:', REDIRECT_URI); - console.log('Requesting scopes:', SCOPES.join(' ')); - - // Auth request uses the Android client ID. - // Android OAuth clients are verified by package name + SHA-1 signing cert, - // so Google accepts the package-name scheme redirect without URI registration. - // - // NOTE: Do NOT use include_granted_scopes — we need a clean token with - // exactly the scopes listed above. Old tokens may carry stale scopes - // (e.g. photoslibrary instead of photoslibrary.readonly) that no longer - // work with the Photos Library API. - const authRequest = new AuthSession.AuthRequest({ - clientId: ANDROID_CLIENT_ID, - scopes: SCOPES, - redirectUri: REDIRECT_URI, - responseType: AuthSession.ResponseType.Code, - usePKCE: true, - extraParams: { - access_type: 'offline', - prompt: 'consent', - }, - }); - - const result = await authRequest.promptAsync(GOOGLE_DISCOVERY); - - if (result.type !== 'success') { - throw new Error('Google authentication was cancelled or failed'); - } - - // Exchange authorization code for tokens using the Android client ID. - // Android clients are public (no client secret required) — the PKCE - // code_verifier is used to verify the exchange instead. - const tokenBody = new URLSearchParams({ - client_id: ANDROID_CLIENT_ID, - code: result.params.code, - code_verifier: authRequest.codeVerifier, - grant_type: 'authorization_code', - redirect_uri: REDIRECT_URI, - }); - - const tokenResponse = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: tokenBody.toString(), - }); - - const tokenData = await tokenResponse.json(); - - if (tokenData.error) { - throw new Error(tokenData.error_description || tokenData.error); - } - - this.accessToken = tokenData.access_token; - this.refreshToken = tokenData.refresh_token || null; - this.idToken = tokenData.id_token || null; - this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000; - - // Log the scopes Google actually granted (the token response includes a `scope` field) - console.log('Google token granted scopes:', tokenData.scope || '(none returned)'); - - // Also verify via tokeninfo endpoint for a definitive answer - try { - const infoRes = await fetch( - `https://oauth2.googleapis.com/tokeninfo?access_token=${this.accessToken}` - ); - const infoData = await infoRes.json(); - console.log('Google tokeninfo scopes:', infoData.scope || '(none)'); - console.log('Google tokeninfo audience:', infoData.aud || '(none)'); - } catch (e) { - console.log('Could not fetch tokeninfo:', e.message); - } - - const userInfo = await this.fetchUserInfo(); - this.email = userInfo?.email || 'google-user'; - + if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') { + console.warn('Google Auth: no Android client ID — using placeholder mode'); + this.accessToken = 'placeholder_google_token_' + Date.now(); + this.refreshToken = 'placeholder_refresh_' + Date.now(); + this.expiresAt = Date.now() + 3600000; + this.email = 'google-user@placeholder.com'; return { success: true, accessToken: this.accessToken, refreshToken: this.refreshToken, email: this.email, }; - } catch (error) { - console.error('Google authentication error:', error); - throw error; } + + console.log('OAuth redirect URI:', REDIRECT_URI); + console.log('Requesting scopes:', SCOPES.join(' ')); + + const authRequest = new AuthSession.AuthRequest({ + clientId: ANDROID_CLIENT_ID, + scopes: SCOPES, + redirectUri: REDIRECT_URI, + responseType: AuthSession.ResponseType.Code, + usePKCE: true, + extraParams: { + access_type: 'offline', + prompt: 'consent', + }, + }); + + const result = await authRequest.promptAsync(GOOGLE_DISCOVERY); + + if (result.type !== 'success') { + const err = new Error('Google authentication was cancelled or failed'); + logError('Google Auth', err); + throw err; + } + + // Exchange authorization code for tokens. + // Android clients are public — PKCE code_verifier secures the exchange. + const tokenBody = new URLSearchParams({ + client_id: ANDROID_CLIENT_ID, + code: result.params.code, + code_verifier: authRequest.codeVerifier, + grant_type: 'authorization_code', + redirect_uri: REDIRECT_URI, + }); + + const tokenResponse = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenBody.toString(), + }); + + const tokenData = await tokenResponse.json(); + + if (tokenData.error) { + const err = new Error(tokenData.error_description || tokenData.error); + logError('Google Token Exchange', err); + throw err; + } + + this.accessToken = tokenData.access_token; + this.refreshToken = tokenData.refresh_token || null; + this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000; + + console.log('Google token granted scopes:', tokenData.scope || '(none returned)'); + + const userInfo = await this.fetchUserInfo(); + this.email = userInfo?.email || 'google-user'; + + return { + success: true, + accessToken: this.accessToken, + refreshToken: this.refreshToken, + email: this.email, + }; } async fetchUserInfo() { @@ -162,29 +137,28 @@ class GoogleAUTH extends AuthServiceBase { } async refreshAccessToken() { - try { - const body = new URLSearchParams({ - client_id: ANDROID_CLIENT_ID, - refresh_token: this.refreshToken, - grant_type: 'refresh_token', - }); + const body = new URLSearchParams({ + client_id: ANDROID_CLIENT_ID, + refresh_token: this.refreshToken, + grant_type: 'refresh_token', + }); - const response = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: body.toString(), - }); + const response = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); - const data = await response.json(); - if (data.error) throw new Error(data.error_description || data.error); - - this.accessToken = data.access_token; - this.expiresAt = Date.now() + (data.expires_in || 3600) * 1000; - return this.accessToken; - } catch (error) { - console.error('Token refresh error:', error); - throw error; + const data = await response.json(); + if (data.error) { + const err = new Error(data.error_description || data.error); + logError('Token Refresh', err); + throw err; } + + this.accessToken = data.access_token; + this.expiresAt = Date.now() + (data.expires_in || 3600) * 1000; + return this.accessToken; } async revokeAccess() { @@ -195,13 +169,12 @@ class GoogleAUTH extends AuthServiceBase { }); } } catch (err) { - console.error('Revoke error:', err); + logError('Revoke', err); } this.accessToken = null; this.refreshToken = null; this.expiresAt = null; this.email = null; - this.idToken = null; return { success: true }; } @@ -216,7 +189,6 @@ class GoogleAUTH extends AuthServiceBase { // Use the mediaItems:search endpoint (POST) instead of the deprecated // GET /v1/mediaItems which Google shut down in 2025. - // An empty filters body returns ALL photos, ordered by creation time. const body = { pageSize }; if (pageToken) body.pageToken = pageToken; @@ -242,23 +214,16 @@ class GoogleAUTH extends AuthServiceBase { errorJson?.error?.message || errorBody || `HTTP ${response.status}`; const googleStatus = errorJson?.error?.status || ''; - console.error( - `Google Photos API ${response.status} (${googleStatus}):`, - googleMessage, - ); + logError('Google Photos API', `${response.status} (${googleStatus}): ${googleMessage}`); - // 401 → token expired, try one refresh if (response.status === 401 && this.refreshToken && !pageToken) { console.log('Token expired, refreshing and retrying…'); await this.refreshAccessToken(); return this.getPhotos(pageSize, pageToken); } - // 403 → scope or API issue if (response.status === 403) { - const isScopeProblem = - googleMessage.toLowerCase().includes('scope'); - + const isScopeProblem = googleMessage.toLowerCase().includes('scope'); return { mediaItems: [], nextPageToken: null, @@ -292,7 +257,7 @@ class GoogleAUTH extends AuthServiceBase { nextPageToken: data.nextPageToken || null, }; } catch (error) { - console.error('Get photos error:', error); + logError('Get Photos', error); return { mediaItems: [], nextPageToken: null }; } } @@ -310,21 +275,19 @@ class GoogleAUTH extends AuthServiceBase { if (!response.ok) { const errorBody = await response.text(); - console.error(`Google Albums API error ${response.status}:`, errorBody); + logError('Google Albums API', `${response.status}: ${errorBody}`); return { albums: [], nextPageToken: null }; } const data = await response.json(); return { albums: data.albums || [], nextPageToken: data.nextPageToken || null }; } catch (error) { - console.error('Get albums error:', error); + logError('Get Albums', error); return { albums: [], nextPageToken: null }; } } - // NOTE: Upload requires the `photoslibrary.appendonly` scope which is not - // requested by default (read-only mode). Re-authenticate with upload scopes - // before calling this method. - async uploadPhoto(filePath, filename, mimeType = 'image/jpeg') { + // NOTE: Upload requires the `photoslibrary.appendonly` scope. + async uploadPhoto(filePath, filename) { try { const token = await this.getAccessToken(); @@ -339,7 +302,10 @@ class GoogleAUTH extends AuthServiceBase { body: await fetch(filePath).then((r) => r.blob()), }); - if (!uploadResponse.ok) throw new Error('Upload bytes failed'); + if (!uploadResponse.ok) { + logError('Upload Photo', 'Upload bytes failed: HTTP ' + uploadResponse.status); + return null; + } const uploadToken = await uploadResponse.text(); const createResponse = await fetch( @@ -353,11 +319,14 @@ class GoogleAUTH extends AuthServiceBase { } ); - if (!createResponse.ok) throw new Error('Create media item failed'); + if (!createResponse.ok) { + logError('Upload Photo', 'Create media item failed: HTTP ' + createResponse.status); + return null; + } return await createResponse.json(); } catch (error) { - console.error('Upload photo error:', error); - throw error; + logError('Upload Photo', error); + return null; } } } diff --git a/src/components/MediaViewer.js b/src/components/MediaViewer.js index c4ba5c8..2ab57a2 100644 --- a/src/components/MediaViewer.js +++ b/src/components/MediaViewer.js @@ -71,7 +71,7 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => { } } }, - [activeIndex, mediaList] + [activeIndex, mediaList, thumbListRef] ); const onThumbPress = (index) => { diff --git a/src/screens/AlbumsScreen.js b/src/screens/AlbumsScreen.js index d0221d9..811b1fa 100644 --- a/src/screens/AlbumsScreen.js +++ b/src/screens/AlbumsScreen.js @@ -13,7 +13,7 @@ const ALBUM_COLUMNS = 2; const ALBUM_GAP = 12; const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS; -const CollectionsScreen = ({ route, navigation }) => { +const CollectionsScreen = ({ route }) => { const userId = route?.params?.userId; const { theme } = useTheme(); const [sections, setSections] = useState([]); diff --git a/src/services/ErrorHandler.js b/src/services/ErrorHandler.js new file mode 100644 index 0000000..81fb917 --- /dev/null +++ b/src/services/ErrorHandler.js @@ -0,0 +1,36 @@ +import { Alert } from 'react-native'; + +/** + * Centralised error handler for the app. + * + * Usage: + * import { logError, alertError } from '../services/ErrorHandler'; + * + * logError('Google Auth', error); // console.error only + * alertError('Upload Failed', error); // Alert + console.error + */ + +/** + * Log an error to the console with a contextual tag. + * @param {string} tag – short label, e.g. 'Google Auth', 'NAS' + * @param {Error|string} error + */ +export const logError = (tag, error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[${tag}] ${message}`); +}; + +/** + * Show a user-facing Alert AND log to the console. + * @param {string} title – Alert title + * @param {Error|string} error + * @param {Array} [buttons] – optional Alert button array + */ +export const alertError = (title, error, buttons) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[${title}] ${message}`); + Alert.alert(title, message, buttons || [{ text: 'OK' }]); +}; + +export default { logError, alertError }; +