From f6c61ab829f80093cd6396460262040389f13605 Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Tue, 24 Feb 2026 23:23:53 +0000 Subject: [PATCH 1/6] add centralized error handling and improve Google Auth error logging --- TEST/testGooglePhotosAPI.js | 51 ++++--- src/AUTH/GoogleAUTH.js | 245 +++++++++++++++------------------- src/components/MediaViewer.js | 2 +- src/screens/AlbumsScreen.js | 2 +- src/services/ErrorHandler.js | 36 +++++ 5 files changed, 175 insertions(+), 161 deletions(-) create mode 100644 src/services/ErrorHandler.js 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 }; + From 5c096fbab424e3e75559782916fad0f636c8a8bf Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Tue, 24 Feb 2026 23:55:04 +0000 Subject: [PATCH 2/6] feat: migrate to Google Photos Picker API and update related documentation due to normal library api being depricated (kept for added media from app) --- TEST/testGooglePhotosAPI.js | 98 ++++++---- ignorefiles/GOOGLE_OAUTH_SETUP.md | 12 +- src/API/AndroidAPI.js | 31 +-- src/AUTH/GoogleAUTH.js | 313 +++++++++++++++++------------- src/screens/HomeScreen.js | 110 ++++++++++- 5 files changed, 363 insertions(+), 201 deletions(-) diff --git a/TEST/testGooglePhotosAPI.js b/TEST/testGooglePhotosAPI.js index 8dba120..051b921 100644 --- a/TEST/testGooglePhotosAPI.js +++ b/TEST/testGooglePhotosAPI.js @@ -1,27 +1,26 @@ -// Google Photos API Test +// Google Photos Picker API Test // // Usage: // node TEST/testGooglePhotosAPI.js // GOOGLE_ACCESS_TOKEN=ya29.xxx node TEST/testGooglePhotosAPI.js // +// The token must have been granted the scope: +// https://www.googleapis.com/auth/photospicker.mediaitems.readonly +// // Getting a token from OAuth Playground (https://developers.google.com/oauthplayground): // 1. Click the gear icon → check "Use your own OAuth credentials" // 2. Enter your Client ID and Client Secret from Google Cloud Console -// 3. In the left panel select ALL of these scopes: -// https://www.googleapis.com/auth/photoslibrary -// https://www.googleapis.com/auth/photoslibrary.appendonly -// https://www.googleapis.com/auth/userinfo.email -// https://www.googleapis.com/auth/userinfo.profile +// 3. In "Input your own scopes" enter: +// https://www.googleapis.com/auth/photospicker.mediaitems.readonly // 4. Click "Authorize APIs" and sign in with your test Google account // 5. Click "Exchange authorization code for tokens" // 6. Copy the Access token (starts with ya29.) and pass it here -// -// Note: if you only see 403 PERMISSION_DENIED, the token was issued without -// the photoslibrary scope — go back and re-authorise with all scopes above. -const API_BASE = 'https://photoslibrary.googleapis.com/v1'; +const PICKER_BASE = 'https://photospicker.googleapis.com/v1'; const USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo'; +function delay(ms) { return new Promise((r) => setTimeout(r, ms)); } + async function main() { const token = process.argv[2] || process.env.GOOGLE_ACCESS_TOKEN; @@ -30,13 +29,13 @@ async function main() { console.error('Usage: node TEST/testGooglePhotosAPI.js '); console.error('Or set: GOOGLE_ACCESS_TOKEN=ya29.xxx node TEST/testGooglePhotosAPI.js'); console.error('\nTo obtain a token go to https://developers.google.com/oauthplayground'); - console.error('and authorise the Photos Library API v1 scope.\n'); + console.error('and authorise the photospicker.mediaitems.readonly scope.\n'); process.exit(1); } const headers = { Authorization: `Bearer ${token}` }; - console.log('\n--- Google Photos API Test ---\n'); + console.log('\n--- Google Photos Picker API Test ---\n'); // Test 1 — User info console.log('[1] Fetching user info...'); @@ -52,48 +51,65 @@ async function main() { console.log(` Failed: ${err.message}\n`); } - // Test 2 — Search media items (replaces deprecated GET /mediaItems) - console.log('[2] Searching media items (first 10)...'); + // Test 2 — Create Picker session + console.log('[2] Creating Picker session...'); + let sessionId = null; try { - const res = await fetch(`${API_BASE}/mediaItems:search`, { + const res = await fetch(`${PICKER_BASE}/sessions`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ pageSize: 10 }), + body: JSON.stringify({}), }); 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(); + const session = await res.json(); + sessionId = session.id; + console.log(` Session created: ${sessionId}`); + console.log(` mediaItemsSet: ${session.mediaItemsSet}\n`); + + // Wait for session to be ready + if (!session.mediaItemsSet) { + console.log(' Waiting for session to become ready...'); + for (let i = 0; i < 10; i++) { + await delay(1500); + const pollRes = await fetch(`${PICKER_BASE}/sessions/${sessionId}`, { headers }); + const pollData = await pollRes.json(); + if (pollData.mediaItemsSet) { + console.log(' Session is ready!\n'); + break; + } + if (i === 9) console.log(' Session timed out.\n'); + } + } } } catch (err) { console.log(` Failed: ${err.message}\n`); } - // Test 3 — List albums - console.log('[3] Listing albums...'); - try { - const res = await fetch(`${API_BASE}/albums?pageSize=10`, { headers }); - 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(); + // Test 3 — List media items from session + if (sessionId) { + console.log('[3] Fetching media items from session...'); + try { + const params = new URLSearchParams({ sessionId, pageSize: '10' }); + const res = await fetch(`${PICKER_BASE}/mediaItems?${params.toString()}`, { headers }); + 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) => { + const file = item.mediaFile || {}; + console.log(` ${i + 1}. ${file.filename || '(no name)'} (${file.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`); } - } catch (err) { - console.log(` Failed: ${err.message}\n`); } console.log('--- Tests complete ---\n'); diff --git a/ignorefiles/GOOGLE_OAUTH_SETUP.md b/ignorefiles/GOOGLE_OAUTH_SETUP.md index ed54de9..42a839b 100644 --- a/ignorefiles/GOOGLE_OAUTH_SETUP.md +++ b/ignorefiles/GOOGLE_OAUTH_SETUP.md @@ -5,10 +5,11 @@ 2. Click **Select a project** → **New Project** 3. Name it and click **Create** -## Step 2: Enable the Photos Library API +## Step 2: Enable the Photos Picker API 1. Go to **APIs & Services** → **Library** -2. Search for **Photos Library API** → **Enable** it -3. ⚠️ This step is **required** — without it, all Photos API calls return 403 +2. Search for **Photos Picker API** → **Enable** it +3. ⚠️ This step is **required** — without it, all Picker API calls fail +4. (The old "Photos Library API" is deprecated and no longer works) ## Step 3: Configure the OAuth Consent Screen 1. Go to **APIs & Services** → **OAuth consent screen** @@ -19,13 +20,12 @@ - **Developer contact**: your email 4. **Scopes** → Add or Remove Scopes → add these: - `openid` - - `https://www.googleapis.com/auth/photoslibrary` + - `https://www.googleapis.com/auth/photospicker.mediaitems.readonly` - `https://www.googleapis.com/auth/userinfo.email` - `https://www.googleapis.com/auth/userinfo.profile` 5. **Test users** → **Add your Google account email address** - ⚠️ This is **critical** — if your email is not listed as a test user, - Google will silently strip the Photos scope from the granted token, - causing a 403 "insufficient authentication scopes" error. + Google will silently strip the Photos Picker scope from the granted token. ## Step 4: Create an Android OAuth Client ID 1. Go to **APIs & Services** → **Credentials** diff --git a/src/API/AndroidAPI.js b/src/API/AndroidAPI.js index 71c6a63..36e2120 100644 --- a/src/API/AndroidAPI.js +++ b/src/API/AndroidAPI.js @@ -1,10 +1,13 @@ -// Google Photos Library API wrapper — uses the provided access token getter for authenticated requests +// Google Photos Picker API wrapper — replaces the deprecated Photos Library API. +// Uses sessions to access user photos from Google Photos cloud. const GooglePhotosAPI = (getAccessToken) => { + const PICKER_ROOT = 'https://photospicker.googleapis.com/v1'; + const callFetch = async (endpoint, method = 'GET', dataObj = null) => { const token = await getAccessToken(); if (!token) return { isSuccess: false, message: 'Missing access token' }; - let requestObj = { + const requestObj = { method, headers: { Authorization: `Bearer ${token}` }, }; @@ -25,21 +28,19 @@ const GooglePhotosAPI = (getAccessToken) => { } }; - const API_ROOT = 'https://photoslibrary.googleapis.com/v1'; - return { - listMediaItems: (pageSize = 25, pageToken) => { - const body = { pageSize }; - if (pageToken) body.pageToken = pageToken; - return callFetch(`${API_ROOT}/mediaItems:search`, 'POST', body); + // Create a Picker session — must be called before listing items + createSession: () => callFetch(`${PICKER_ROOT}/sessions`, 'POST', {}), + + // Poll session status + getSession: (sessionId) => callFetch(`${PICKER_ROOT}/sessions/${sessionId}`, 'GET'), + + // List media items from a ready session + listMediaItems: (sessionId, pageSize = 25, pageToken) => { + const params = new URLSearchParams({ sessionId, pageSize: String(pageSize) }); + if (pageToken) params.append('pageToken', pageToken); + return callFetch(`${PICKER_ROOT}/mediaItems?${params.toString()}`, 'GET'); }, - getMediaItem: (mediaItemId) => callFetch(`${API_ROOT}/mediaItems/${mediaItemId}`, 'GET'), - searchMediaItems: (filters) => callFetch(`${API_ROOT}/mediaItems:search`, 'POST', filters), - createAlbum: (title) => callFetch(`${API_ROOT}/albums`, 'POST', { album: { title } }), - addItemsToAlbum: (albumId, mediaItemIds) => - callFetch(`${API_ROOT}/albums/${albumId}:batchAddMediaItems`, 'POST', { mediaItemIds }), - removeItemsFromAlbum: (albumId, mediaItemIds) => - callFetch(`${API_ROOT}/albums/${albumId}:batchRemoveMediaItems`, 'POST', { mediaItemIds }), }; }; diff --git a/src/AUTH/GoogleAUTH.js b/src/AUTH/GoogleAUTH.js index 029ba2b..63827ad 100644 --- a/src/AUTH/GoogleAUTH.js +++ b/src/AUTH/GoogleAUTH.js @@ -6,10 +6,9 @@ import { logError } from '../services/ErrorHandler'; WebBrowser.maybeCompleteAuthSession(); -// Android client ID — used for both auth requests and token exchange. -// Created in Google Cloud Console as "Android" type with package name -// com.bobthebob.massphotoapp and the SHA-1 from `eas credentials --platform android`. -// Android clients are public (no client_secret needed) — PKCE verifies instead. +// Android client ID — created in Google Cloud Console as "Android" type +// with package name com.bobthebob.massphotoapp and the SHA-1 from +// `eas credentials --platform android`. const ANDROID_CLIENT_ID = '313090284964-018uf4v1bmmrra09lvmpnuug2qs09047.apps.googleusercontent.com'; const GOOGLE_DISCOVERY = { @@ -18,16 +17,18 @@ const GOOGLE_DISCOVERY = { revocationEndpoint: 'https://oauth2.googleapis.com/revoke', }; +// The old Photos Library API (photoslibrary.googleapis.com) was shut down +// in 2025. Google's replacement is the Photos Picker API which uses its +// own scope. const SCOPES = [ 'openid', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/photoslibrary', + 'https://www.googleapis.com/auth/photospicker.mediaitems.readonly', ]; -// 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 +const PICKER_API = 'https://photospicker.googleapis.com/v1'; + const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`; class GoogleAUTH extends AuthServiceBase { @@ -39,6 +40,8 @@ class GoogleAUTH extends AuthServiceBase { this.email = null; } + // ───────────────────── Authentication ───────────────────── + async authenticate() { if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') { console.warn('Google Auth: no Android client ID — using placeholder mode'); @@ -77,8 +80,6 @@ class GoogleAUTH extends AuthServiceBase { 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, @@ -183,150 +184,198 @@ class GoogleAUTH extends AuthServiceBase { return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' }; } - async getPhotos(pageSize = 50, pageToken = null) { - try { - const token = await this.getAccessToken(); + // ───────────────── Photos Picker API ────────────────── + // + // The Picker API is interactive — the user must open the pickerUri in a + // browser to select which photos to share with the app. Once the user + // finishes selecting, mediaItemsSet becomes true and we can fetch items. + // + // Flow: + // 1. POST /v1/sessions → get { id, pickerUri } + // 2. Open pickerUri in browser → user selects photos → browser closes + // 3. Poll GET /v1/sessions/{id} until mediaItemsSet = true + // 4. GET /v1/mediaItems?sessionId={id}&pageSize=N → selected items - // Use the mediaItems:search endpoint (POST) instead of the deprecated - // GET /v1/mediaItems which Google shut down in 2025. - const body = { pageSize }; - if (pageToken) body.pageToken = pageToken; + /** + * Create a new Picker session. + * Returns the full session object { id, pickerUri, mediaItemsSet, ... } + * or { error: { code, message } } on failure. + */ + async _createPickerSession() { + const token = await this.getAccessToken(); + const res = await fetch(`${PICKER_API}/sessions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }); - const url = 'https://photoslibrary.googleapis.com/v1/mediaItems:search'; - const response = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), + if (!res.ok) { + const body = await res.text(); + logError('Picker Session Create', `${res.status}: ${body}`); + return { error: { code: res.status, message: body } }; + } + + return await res.json(); + } + + /** + * Poll a session until mediaItemsSet becomes true. + * Waits up to ~30 seconds (user needs time to select photos). + */ + async _waitForSession(sessionId) { + const token = await this.getAccessToken(); + const maxAttempts = 20; + const delayMs = 1500; + + for (let i = 0; i < maxAttempts; i++) { + const res = await fetch(`${PICKER_API}/sessions/${sessionId}`, { + headers: { Authorization: `Bearer ${token}` }, }); - if (!response.ok) { - let errorBody = ''; - let errorJson = null; - try { - errorBody = await response.text(); - errorJson = JSON.parse(errorBody); - } catch (_) { /* not JSON */ } - - const googleMessage = - errorJson?.error?.message || errorBody || `HTTP ${response.status}`; - const googleStatus = errorJson?.error?.status || ''; - - logError('Google Photos API', `${response.status} (${googleStatus}): ${googleMessage}`); - - if (response.status === 401 && this.refreshToken && !pageToken) { - console.log('Token expired, refreshing and retrying…'); - await this.refreshAccessToken(); - return this.getPhotos(pageSize, pageToken); - } - - if (response.status === 403) { - const isScopeProblem = googleMessage.toLowerCase().includes('scope'); - return { - mediaItems: [], - nextPageToken: null, - error: { - code: 403, - status: googleStatus, - message: googleMessage, - needsReauth: isScopeProblem, - hint: isScopeProblem - ? 'Token is missing the required scope. Re-authenticate in Settings.' - : googleMessage, - }, - }; - } - - return { mediaItems: [], nextPageToken: null }; + if (!res.ok) { + logError('Picker Session Poll', `${res.status}: ${await res.text()}`); + return null; } - const data = await response.json(); - return { - mediaItems: (data.mediaItems || []).map((item) => ({ - source_service: 'GOOGLE_PHOTOS', - source_id: item.id, - title: item.filename || 'Untitled', - description: item.description || '', - local_path: item.baseUrl ? `${item.baseUrl}=w400-h400` : null, - remote_path: item.productUrl || '', - media_type: item.mimeType?.startsWith('video') ? 'video' : 'photo', - created_date: item.mediaMetadata?.creationTime || null, - })), - nextPageToken: data.nextPageToken || null, - }; - } catch (error) { - logError('Get Photos', error); + const session = await res.json(); + if (session.mediaItemsSet) return session; + + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + console.warn('Picker session timed out waiting for user selection'); + return null; + } + + /** + * Fetch media items from a ready Picker session. + */ + async _fetchPickerMediaItems(sessionId, pageSize = 100, pageToken = null) { + const token = await this.getAccessToken(); + const params = new URLSearchParams({ + sessionId, + pageSize: String(pageSize), + }); + if (pageToken) params.append('pageToken', pageToken); + + const res = await fetch(`${PICKER_API}/mediaItems?${params.toString()}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + const body = await res.text(); + logError('Picker MediaItems', `${res.status}: ${body}`); return { mediaItems: [], nextPageToken: null }; } + + return await res.json(); } - async getAlbums(pageSize = 50, pageToken = null) { + /** + * Normalise Picker API media items to the app's standard format. + */ + _normalisePickerItems(items) { + return (items || []).map((item) => { + const file = item.mediaFile || {}; + const isVideo = file.mimeType?.startsWith('video'); + return { + source_service: 'GOOGLE_PHOTOS', + source_id: item.id || file.id, + title: file.filename || 'Untitled', + description: '', + local_path: file.baseUrl ? `${file.baseUrl}=w400-h400` : null, + remote_path: file.baseUrl || '', + media_type: isVideo ? 'video' : 'photo', + created_date: file.mediaFileMetadata?.creationTime || null, + }; + }); + } + + /** + * Full interactive flow: create session → open picker in browser → + * wait for user to select photos → fetch the selected items. + * + * @returns {{ mediaItems, nextPageToken, sessionId, error? }} + */ + async pickPhotos() { try { - const token = await this.getAccessToken(); - const params = new URLSearchParams({ pageSize: String(pageSize) }); - if (pageToken) params.append('pageToken', pageToken); + // 1. Create session + console.log('Creating Google Photos Picker session…'); + const session = await this._createPickerSession(); - const response = await fetch( - `https://photoslibrary.googleapis.com/v1/albums?${params.toString()}`, - { headers: { Authorization: `Bearer ${token}` } } - ); - - if (!response.ok) { - const errorBody = await response.text(); - logError('Google Albums API', `${response.status}: ${errorBody}`); - return { albums: [], nextPageToken: null }; + if (session?.error) { + return { + mediaItems: [], nextPageToken: null, sessionId: null, + error: { code: session.error.code, message: session.error.message }, + }; } - const data = await response.json(); - return { albums: data.albums || [], nextPageToken: data.nextPageToken || null }; + if (!session?.id || !session?.pickerUri) { + return { + mediaItems: [], nextPageToken: null, sessionId: null, + error: { code: 0, message: 'Failed to create Picker session' }, + }; + } + + console.log('Picker session created:', session.id); + console.log('Opening picker for user selection…'); + + // 2. Open the picker URI in a browser for the user to select photos + await WebBrowser.openBrowserAsync(session.pickerUri, { + dismissButtonStyle: 'done', + presentationStyle: 'pageSheet', + }); + + console.log('Browser closed — polling for selected photos…'); + + // 3. Poll until the user's selections are ready + const ready = await this._waitForSession(session.id); + if (!ready) { + return { + mediaItems: [], nextPageToken: null, sessionId: session.id, + error: { code: 0, message: 'No photos were selected or the picker was dismissed' }, + }; + } + + // 4. Fetch selected items + const result = await this._fetchPickerMediaItems(session.id, 100, null); + const mediaItems = this._normalisePickerItems(result.mediaItems); + + console.log(`Picker returned ${mediaItems.length} selected items`); + + return { + mediaItems, + nextPageToken: result.nextPageToken || null, + sessionId: session.id, + }; } catch (error) { - logError('Get Albums', error); - return { albums: [], nextPageToken: null }; + logError('Pick Photos', error); + return { mediaItems: [], nextPageToken: null, sessionId: null }; } } - // NOTE: Upload requires the `photoslibrary.appendonly` scope. - async uploadPhoto(filePath, filename) { + /** + * Fetch more items from an existing Picker session (pagination). + */ + async getMorePhotos(pageSize = 100, pageToken, sessionId) { try { - const token = await this.getAccessToken(); - - const uploadResponse = await fetch('https://photoslibrary.googleapis.com/v1/uploads', { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/octet-stream', - 'X-Goog-Upload-File-Name': filename, - 'X-Goog-Upload-Protocol': 'raw', - }, - body: await fetch(filePath).then((r) => r.blob()), - }); - - if (!uploadResponse.ok) { - logError('Upload Photo', 'Upload bytes failed: HTTP ' + uploadResponse.status); - return null; + if (!sessionId || !pageToken) { + return { mediaItems: [], nextPageToken: null, sessionId }; } - const uploadToken = await uploadResponse.text(); - const createResponse = await fetch( - 'https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate', - { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - newMediaItems: [{ simpleMediaItem: { uploadToken, fileName: filename } }], - }), - } - ); + const result = await this._fetchPickerMediaItems(sessionId, pageSize, pageToken); + const mediaItems = this._normalisePickerItems(result.mediaItems); - if (!createResponse.ok) { - logError('Upload Photo', 'Create media item failed: HTTP ' + createResponse.status); - return null; - } - return await createResponse.json(); + return { + mediaItems, + nextPageToken: result.nextPageToken || null, + sessionId, + }; } catch (error) { - logError('Upload Photo', error); - return null; + logError('Get More Photos', error); + return { mediaItems: [], nextPageToken: null, sessionId }; } } } diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index c97fbe8..86d63d6 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -6,6 +6,7 @@ import { useFocusEffect } from '@react-navigation/native'; import { useTheme } from '../services/ThemeContext'; import DatabaseService from '../services/DatabaseService'; import DeviceMediaService from '../services/DeviceMediaService'; +import GoogleAUTH from '../AUTH/GoogleAUTH'; import NASAUTH from '../AUTH/NASAUTH'; import TopBar from '../components/TopBar'; import PhotoGrid from '../components/PhotoGrid'; @@ -26,6 +27,8 @@ const HomeScreen = ({ navigation, route }) => { const [viewerVisible, setViewerVisible] = useState(false); const [deviceEndCursor, setDeviceEndCursor] = useState(null); const [deviceHasMore, setDeviceHasMore] = useState(false); + const [googleNextPage, setGoogleNextPage] = useState(null); + const [googleSessionId, setGoogleSessionId] = useState(null); const [loadingMore, setLoadingMore] = useState(false); useFocusEffect( @@ -56,7 +59,7 @@ const HomeScreen = ({ navigation, route }) => { try { const newMedia = []; - // ─── Device photos (camera roll + Google Photos synced images) ─── + // ─── Device photos (local camera roll, screenshots, etc.) ─── try { const deviceResult = await DeviceMediaService.getPhotos(100); if (deviceResult.mediaItems.length) { @@ -68,6 +71,59 @@ const HomeScreen = ({ navigation, route }) => { console.error('Device media fetch error:', err); } + // ─── Google Photos cloud (via Picker API — includes cloud-only photos) ─── + const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS'); + if (googleAccount) { + try { + const data = googleAccount.account_data || {}; + const googleAuth = new GoogleAUTH(); + googleAuth.accessToken = data.accessToken || null; + googleAuth.refreshToken = data.refreshToken || null; + googleAuth.expiresAt = data.expiresAt || 0; + + let photos = await googleAuth.getPhotos(100, null, null); + + // If the stored token has stale scopes (missing photospicker scope), + // automatically re-authenticate to get a fresh token and retry. + if (photos?.error?.code === 403) { + console.log('Stored token missing Picker scope — re-authenticating…'); + try { + await googleAuth.revokeAccess(); + const fresh = await googleAuth.authenticate(); + await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', { + accessToken: fresh.accessToken, + refreshToken: fresh.refreshToken || null, + expiresAt: googleAuth.expiresAt, + email: fresh.email || data.email || 'google-user', + }); + photos = await googleAuth.getPhotos(100, null, null); + } catch (authErr) { + console.warn('Google re-auth failed:', authErr.message); + } + } + + if (photos?.error) { + console.warn('Google Photos cloud fetch failed:', photos.error.message); + } else { + if (photos?.mediaItems?.length) newMedia.push(...photos.mediaItems); + setGoogleNextPage(photos?.nextPageToken || null); + setGoogleSessionId(photos?.sessionId || null); + } + + // Persist any refreshed tokens back to DB + if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) { + await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', { + accessToken: googleAuth.accessToken, + refreshToken: googleAuth.refreshToken || data.refreshToken, + expiresAt: googleAuth.expiresAt, + email: data.email || 'google-user', + }); + } + } catch (err) { + console.error('Google Photos cloud fetch error:', err); + } + } + // ─── NAS fetch ─── const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS'); if (nasAccount) { @@ -89,6 +145,7 @@ const HomeScreen = ({ navigation, route }) => { console.log('iCloud: placeholder — would fetch photos here'); } + // Deduplicate by source_id (Google Photos cloud items may overlap with device items by filename) const unique = Array.from( new Map(newMedia.map((m) => [m.source_id || m.id || m.local_path, m])).values() ); @@ -111,20 +168,59 @@ const HomeScreen = ({ navigation, route }) => { }; const loadMorePhotos = async () => { - if (loadingMore || !deviceHasMore) return; + if (loadingMore) return; + // Load more from whichever source has more pages + const canLoadDevice = deviceHasMore; + const canLoadGoogle = !!googleNextPage; + if (!canLoadDevice && !canLoadGoogle) return; + setLoadingMore(true); try { - const result = await DeviceMediaService.getPhotos(100, deviceEndCursor); - if (result.mediaItems.length) { + const moreMedia = []; + + // More device photos + if (canLoadDevice) { + const result = await DeviceMediaService.getPhotos(100, deviceEndCursor); + if (result.mediaItems.length) moreMedia.push(...result.mediaItems); + setDeviceEndCursor(result.endCursor); + setDeviceHasMore(result.hasNextPage); + } + + // More Google Photos cloud photos (reuse the Picker session) + if (canLoadGoogle) { + const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS'); + if (googleAccount) { + const data = googleAccount.account_data || {}; + const googleAuth = new GoogleAUTH(); + googleAuth.accessToken = data.accessToken || null; + googleAuth.refreshToken = data.refreshToken || null; + googleAuth.expiresAt = data.expiresAt || 0; + + const photos = await googleAuth.getPhotos(100, googleNextPage, googleSessionId); + if (photos?.mediaItems?.length) moreMedia.push(...photos.mediaItems); + setGoogleNextPage(photos?.nextPageToken || null); + if (photos?.sessionId) setGoogleSessionId(photos.sessionId); + + // Persist refreshed tokens + if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) { + await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', { + accessToken: googleAuth.accessToken, + refreshToken: googleAuth.refreshToken || data.refreshToken, + expiresAt: googleAuth.expiresAt, + email: data.email || 'google-user', + }); + } + } + } + + if (moreMedia.length) { setAllMedia((prev) => { - const combined = [...prev, ...result.mediaItems]; + const combined = [...prev, ...moreMedia]; return Array.from( new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values() ); }); } - setDeviceEndCursor(result.endCursor); - setDeviceHasMore(result.hasNextPage); } catch (err) { console.error('Error loading more photos:', err); } finally { From 04046702d34b4fc16d39230c664046ad7290cd04 Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Tue, 24 Feb 2026 23:59:18 +0000 Subject: [PATCH 3/6] feat: implement Google Photos import functionality and save media items to the database --- src/screens/HomeScreen.js | 190 ++++++++++++++++++++------------ src/services/DatabaseService.js | 31 ++++++ 2 files changed, 151 insertions(+), 70 deletions(-) diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index 86d63d6..ea8588b 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -11,6 +11,7 @@ import NASAUTH from '../AUTH/NASAUTH'; import TopBar from '../components/TopBar'; import PhotoGrid from '../components/PhotoGrid'; import MediaViewer from '../components/MediaViewer'; +import { logError } from '../services/ErrorHandler'; import { TouchableOpacity } from 'react-native'; const HomeScreen = ({ navigation, route }) => { @@ -30,6 +31,7 @@ const HomeScreen = ({ navigation, route }) => { const [googleNextPage, setGoogleNextPage] = useState(null); const [googleSessionId, setGoogleSessionId] = useState(null); const [loadingMore, setLoadingMore] = useState(false); + const [importing, setImporting] = useState(false); useFocusEffect( useCallback(() => { @@ -71,57 +73,12 @@ const HomeScreen = ({ navigation, route }) => { console.error('Device media fetch error:', err); } - // ─── Google Photos cloud (via Picker API — includes cloud-only photos) ─── - const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS'); - if (googleAccount) { - try { - const data = googleAccount.account_data || {}; - const googleAuth = new GoogleAUTH(); - googleAuth.accessToken = data.accessToken || null; - googleAuth.refreshToken = data.refreshToken || null; - googleAuth.expiresAt = data.expiresAt || 0; - - let photos = await googleAuth.getPhotos(100, null, null); - - // If the stored token has stale scopes (missing photospicker scope), - // automatically re-authenticate to get a fresh token and retry. - if (photos?.error?.code === 403) { - console.log('Stored token missing Picker scope — re-authenticating…'); - try { - await googleAuth.revokeAccess(); - const fresh = await googleAuth.authenticate(); - await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', { - accessToken: fresh.accessToken, - refreshToken: fresh.refreshToken || null, - expiresAt: googleAuth.expiresAt, - email: fresh.email || data.email || 'google-user', - }); - photos = await googleAuth.getPhotos(100, null, null); - } catch (authErr) { - console.warn('Google re-auth failed:', authErr.message); - } - } - - if (photos?.error) { - console.warn('Google Photos cloud fetch failed:', photos.error.message); - } else { - if (photos?.mediaItems?.length) newMedia.push(...photos.mediaItems); - setGoogleNextPage(photos?.nextPageToken || null); - setGoogleSessionId(photos?.sessionId || null); - } - - // Persist any refreshed tokens back to DB - if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) { - await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', { - accessToken: googleAuth.accessToken, - refreshToken: googleAuth.refreshToken || data.refreshToken, - expiresAt: googleAuth.expiresAt, - email: data.email || 'google-user', - }); - } - } catch (err) { - console.error('Google Photos cloud fetch error:', err); - } + // ─── Previously imported Google Photos (stored in DB) ─── + try { + const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS'); + if (googleMedia?.length) newMedia.push(...googleMedia); + } catch (err) { + console.error('DB Google media fetch error:', err); } // ─── NAS fetch ─── @@ -145,7 +102,7 @@ const HomeScreen = ({ navigation, route }) => { console.log('iCloud: placeholder — would fetch photos here'); } - // Deduplicate by source_id (Google Photos cloud items may overlap with device items by filename) + // Deduplicate const unique = Array.from( new Map(newMedia.map((m) => [m.source_id || m.id || m.local_path, m])).values() ); @@ -158,6 +115,76 @@ const HomeScreen = ({ navigation, route }) => { } }; + // ─── Import from Google Photos (opens picker in browser) ─── + const importGooglePhotos = async () => { + const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS'); + if (!googleAccount) { + console.warn('No Google account linked'); + return; + } + + setImporting(true); + try { + const data = googleAccount.account_data || {}; + const googleAuth = new GoogleAUTH(); + googleAuth.accessToken = data.accessToken || null; + googleAuth.refreshToken = data.refreshToken || null; + googleAuth.expiresAt = data.expiresAt || 0; + + let result = await googleAuth.pickPhotos(); + + // If 403 (stale scopes), auto re-auth and retry + if (result?.error?.code === 403) { + console.log('Stale token — re-authenticating…'); + await googleAuth.revokeAccess(); + const fresh = await googleAuth.authenticate(); + await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', { + accessToken: fresh.accessToken, + refreshToken: fresh.refreshToken || null, + expiresAt: googleAuth.expiresAt, + email: fresh.email || data.email || 'google-user', + }); + result = await googleAuth.pickPhotos(); + } + + if (result?.error) { + logError('Import Google Photos', result.error.message); + } else if (result?.mediaItems?.length) { + // Save imported items to DB so they persist across app restarts + for (const item of result.mediaItems) { + await DatabaseService.saveMediaItem(userId, item); + } + + setGoogleNextPage(result.nextPageToken || null); + setGoogleSessionId(result.sessionId || null); + + // Merge into current view + setAllMedia((prev) => { + const combined = [...prev, ...result.mediaItems]; + return Array.from( + new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values() + ); + }); + + console.log(`Imported ${result.mediaItems.length} photos from Google`); + } + + // Persist refreshed tokens + if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) { + await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', { + accessToken: googleAuth.accessToken, + refreshToken: googleAuth.refreshToken || data.refreshToken, + expiresAt: googleAuth.expiresAt, + email: data.email || 'google-user', + }); + } + } catch (err) { + logError('Import Google Photos', err); + } finally { + setImporting(false); + } + }; + const filterMedia = () => { let filtered = allMedia; if (selectedFilter !== 'all') { @@ -169,16 +196,14 @@ const HomeScreen = ({ navigation, route }) => { const loadMorePhotos = async () => { if (loadingMore) return; - // Load more from whichever source has more pages const canLoadDevice = deviceHasMore; - const canLoadGoogle = !!googleNextPage; + const canLoadGoogle = !!googleNextPage && !!googleSessionId; if (!canLoadDevice && !canLoadGoogle) return; setLoadingMore(true); try { const moreMedia = []; - // More device photos if (canLoadDevice) { const result = await DeviceMediaService.getPhotos(100, deviceEndCursor); if (result.mediaItems.length) moreMedia.push(...result.mediaItems); @@ -186,7 +211,6 @@ const HomeScreen = ({ navigation, route }) => { setDeviceHasMore(result.hasNextPage); } - // More Google Photos cloud photos (reuse the Picker session) if (canLoadGoogle) { const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS'); if (googleAccount) { @@ -196,20 +220,15 @@ const HomeScreen = ({ navigation, route }) => { googleAuth.refreshToken = data.refreshToken || null; googleAuth.expiresAt = data.expiresAt || 0; - const photos = await googleAuth.getPhotos(100, googleNextPage, googleSessionId); - if (photos?.mediaItems?.length) moreMedia.push(...photos.mediaItems); + const photos = await googleAuth.getMorePhotos(100, googleNextPage, googleSessionId); + if (photos?.mediaItems?.length) { + moreMedia.push(...photos.mediaItems); + for (const item of photos.mediaItems) { + await DatabaseService.saveMediaItem(userId, item); + } + } setGoogleNextPage(photos?.nextPageToken || null); if (photos?.sessionId) setGoogleSessionId(photos.sessionId); - - // Persist refreshed tokens - if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) { - await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', { - accessToken: googleAuth.accessToken, - refreshToken: googleAuth.refreshToken || data.refreshToken, - expiresAt: googleAuth.expiresAt, - email: data.email || 'google-user', - }); - } } } @@ -231,6 +250,8 @@ const HomeScreen = ({ navigation, route }) => { const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type); + const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS'); + if (loading && allMedia.length === 0) { return ( @@ -284,12 +305,33 @@ const HomeScreen = ({ navigation, route }) => { + {/* Import from Google Photos button */} + {hasGoogleAccount && ( + + {importing ? ( + + ) : ( + <> + 📷 + Import from Google Photos + + )} + + )} + {filteredMedia.length === 0 ? ( 📸 No Photos Yet - Grant media library permission or take some photos to get started + {hasGoogleAccount + ? 'Tap "Import from Google Photos" above to select cloud photos,\nor grant media library permission to see on-device photos.' + : 'Grant media library permission or take some photos to get started'} { + try { + await DatabaseService.ensureDb(); + if (!userId || !item.source_service) return; + + // Check for existing entry to avoid duplicates + if (item.source_id) { + const existing = await db.getFirstAsync( + 'SELECT id FROM media WHERE user_id = ? AND source_id = ?', + [userId, item.source_id] + ); + if (existing) return existing.id; + } + + const result = await db.runAsync( + `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [userId, item.source_service, item.source_id || null, item.title || null, + item.description || null, item.local_path || null, item.remote_path || null, + item.media_type || null, item.created_date || null] + ); + return result.lastInsertRowId; + } catch (error) { + console.error('Error saving media item:', error); + } + }, + getMediaByUser: async (userId) => { try { await DatabaseService.ensureDb(); From ad0cc1a7f547dffbf00170120753dfcd6f7dc6b8 Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Wed, 25 Feb 2026 00:14:00 +0000 Subject: [PATCH 4/6] feat: enhance database service with centralized error logging and improve media handling across services --- src/AUTH/GoogleAUTH.js | 86 +++++++++- src/components/MediaViewer.js | 45 +++-- src/components/PhotoGrid.js | 35 +++- src/screens/HomeScreen.js | 88 ++++++++-- src/services/DatabaseService.js | 295 +++++++++++++++++++------------- 5 files changed, 384 insertions(+), 165 deletions(-) diff --git a/src/AUTH/GoogleAUTH.js b/src/AUTH/GoogleAUTH.js index 63827ad..c8bb260 100644 --- a/src/AUTH/GoogleAUTH.js +++ b/src/AUTH/GoogleAUTH.js @@ -1,6 +1,7 @@ import * as AuthSession from 'expo-auth-session'; import * as WebBrowser from 'expo-web-browser'; import * as Application from 'expo-application'; +import * as FileSystem from 'expo-file-system'; import { AuthServiceBase } from './AuthServiceBase'; import { logError } from '../services/ErrorHandler'; @@ -29,6 +30,8 @@ const SCOPES = [ const PICKER_API = 'https://photospicker.googleapis.com/v1'; +const CACHE_DIR = `${FileSystem.cacheDirectory}google_photos/`; + const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`; class GoogleAUTH extends AuthServiceBase { @@ -276,6 +279,10 @@ class GoogleAUTH extends AuthServiceBase { /** * Normalise Picker API media items to the app's standard format. + * + * NOTE: The Picker API's baseUrl requires an Authorization header to + * fetch the image. We store it in `remote_path` / `picker_base_url` + * so the caller can download it to a local cache file for rendering. */ _normalisePickerItems(items) { return (items || []).map((item) => { @@ -286,14 +293,80 @@ class GoogleAUTH extends AuthServiceBase { source_id: item.id || file.id, title: file.filename || 'Untitled', description: '', - local_path: file.baseUrl ? `${file.baseUrl}=w400-h400` : null, + // local_path is null until we download it with the auth token + local_path: null, remote_path: file.baseUrl || '', + picker_base_url: file.baseUrl || null, media_type: isVideo ? 'video' : 'photo', + mime_type: file.mimeType || null, created_date: file.mediaFileMetadata?.creationTime || null, }; }); } + /** + * Download Picker images to local cache so can render them. + * + * The Picker API's baseUrl requires an Authorization header. React + * Native's can't add custom headers reliably, so we download + * each photo to a cache file and set local_path to the file:// URI. + * + * @param {Array} items — normalised items from _normalisePickerItems + * @returns {Array} — same items with local_path populated + */ + async downloadPickerPhotos(items) { + const token = await this.getAccessToken(); + + // Ensure cache directory exists + const dirInfo = await FileSystem.getInfoAsync(CACHE_DIR); + if (!dirInfo.exists) { + await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true }); + } + + const results = []; + for (const item of items) { + if (!item.picker_base_url) { + results.push(item); + continue; + } + + try { + // Use the source_id as a safe filename + const safeId = (item.source_id || 'photo').replace(/[^a-zA-Z0-9_-]/g, '_'); + const ext = item.mime_type?.includes('png') ? '.png' + : item.mime_type?.includes('gif') ? '.gif' + : item.mime_type?.includes('webp') ? '.webp' + : '.jpg'; + const localUri = `${CACHE_DIR}${safeId}${ext}`; + + // Skip download if already cached + const fileInfo = await FileSystem.getInfoAsync(localUri); + if (fileInfo.exists && fileInfo.size > 0) { + results.push({ ...item, local_path: localUri }); + continue; + } + + // Download with auth header — append =d to get the full-res download + const downloadUrl = `${item.picker_base_url}=d`; + const download = await FileSystem.downloadAsync(downloadUrl, localUri, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (download.status === 200) { + results.push({ ...item, local_path: download.uri }); + } else { + logError('Download Picker Photo', `HTTP ${download.status} for ${item.title}`); + results.push(item); + } + } catch (err) { + logError('Download Picker Photo', err); + results.push(item); + } + } + + return results; + } + /** * Full interactive flow: create session → open picker in browser → * wait for user to select photos → fetch the selected items. @@ -339,11 +412,13 @@ class GoogleAUTH extends AuthServiceBase { }; } - // 4. Fetch selected items + // 4. Fetch selected items and download to local cache const result = await this._fetchPickerMediaItems(session.id, 100, null); - const mediaItems = this._normalisePickerItems(result.mediaItems); + let mediaItems = this._normalisePickerItems(result.mediaItems); - console.log(`Picker returned ${mediaItems.length} selected items`); + console.log(`Picker returned ${mediaItems.length} selected items, downloading…`); + mediaItems = await this.downloadPickerPhotos(mediaItems); + console.log(`Downloaded ${mediaItems.filter((m) => m.local_path).length} photos to cache`); return { mediaItems, @@ -366,7 +441,8 @@ class GoogleAUTH extends AuthServiceBase { } const result = await this._fetchPickerMediaItems(sessionId, pageSize, pageToken); - const mediaItems = this._normalisePickerItems(result.mediaItems); + let mediaItems = this._normalisePickerItems(result.mediaItems); + mediaItems = await this.downloadPickerPhotos(mediaItems); return { mediaItems, diff --git a/src/components/MediaViewer.js b/src/components/MediaViewer.js index 2ab57a2..8575906 100644 --- a/src/components/MediaViewer.js +++ b/src/components/MediaViewer.js @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect, useCallback } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import { Modal, View, Text, TouchableOpacity, Image, StyleSheet, Dimensions, FlatList, @@ -57,22 +57,22 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => { }, 100); }, [visible, media]); - const onMainScroll = useCallback( - (event) => { - const offsetX = event.nativeEvent.contentOffset.x; - const idx = Math.round(offsetX / SCREEN_WIDTH); - if (idx >= 0 && idx < mediaList.length && idx !== activeIndex) { - setActiveIndex(idx); - setCurrentMedia(mediaList[idx]); - if (thumbListRef.current) { - try { - thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 }); - } catch (e) { /* ignore */ } - } + const activeIndexRef = useRef(0); + + const onMainScroll = (event) => { + const offsetX = event.nativeEvent.contentOffset.x; + const idx = Math.round(offsetX / SCREEN_WIDTH); + if (idx >= 0 && idx < mediaList.length && idx !== activeIndexRef.current) { + activeIndexRef.current = idx; + setActiveIndex(idx); + setCurrentMedia(mediaList[idx]); + if (thumbListRef.current) { + try { + thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 }); + } catch (_) { /* ignore */ } } - }, - [activeIndex, mediaList, thumbListRef] - ); + } + }; const onThumbPress = (index) => { setActiveIndex(index); @@ -189,9 +189,20 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => { Source - {getServiceIcon(displayMedia.source_service)} {getServiceName(displayMedia.source_service)} + {displayMedia.sources && displayMedia.sources.length > 1 + ? displayMedia.sources.map((s) => `${getServiceIcon(s)} ${getServiceName(s)}`).join(' · ') + : `${getServiceIcon(displayMedia.source_service)} ${getServiceName(displayMedia.source_service)}` + } + {displayMedia.sources && displayMedia.sources.length > 1 && ( + + Available on + + {displayMedia.sources.length} services + + + )} {displayMedia.created_date && ( Date diff --git a/src/components/PhotoGrid.js b/src/components/PhotoGrid.js index 6442285..4b56f3a 100644 --- a/src/components/PhotoGrid.js +++ b/src/components/PhotoGrid.js @@ -21,7 +21,31 @@ const PhotoGrid = ({ const itemSize = (width - GRID_GAP * (columns + 1)) / columns; const getServiceIcon = (type) => - ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); + ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); + + const renderSourceBadges = (item) => { + const sources = item.sources && item.sources.length > 0 + ? item.sources + : [item.source_service]; + + if (sources.length === 1) { + // Single source — simple badge + return ( + + {getServiceIcon(sources[0])} + + ); + } + + // Multiple sources — show all icons with a subtle indicator + return ( + + {sources.map((src, i) => ( + {getServiceIcon(src)} + ))} + + ); + }; const renderItem = ({ item }) => ( ▶ )} - - {getServiceIcon(item.source_service)} - + {renderSourceBadges(item)} ); @@ -82,6 +104,11 @@ const styles = StyleSheet.create({ position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2, }, + multiSourceBadge: { + position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.6)', + borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2, + flexDirection: 'row', gap: 2, + }, }); export default PhotoGrid; diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index ea8588b..49099f0 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -14,6 +14,62 @@ import MediaViewer from '../components/MediaViewer'; import { logError } from '../services/ErrorHandler'; import { TouchableOpacity } from 'react-native'; +/** + * Merge media items from different services. + * Items with the same filename (title) are merged into a single entry + * with a `sources` array listing every service it appears in. + * The best local_path is kept (prefer file:// from DEVICE over a cached + * Picker download, since device URIs are always fresh). + */ +const mergeMediaAcrossServices = (items) => { + // First pass: group by normalised filename + const byFilename = new Map(); + const noFilename = []; + + for (const item of items) { + const key = item.title?.toLowerCase().trim(); + if (!key || key === 'untitled') { + noFilename.push({ ...item, sources: [item.source_service] }); + continue; + } + + if (byFilename.has(key)) { + const existing = byFilename.get(key); + // Add this service to the sources list + if (!existing.sources.includes(item.source_service)) { + existing.sources.push(item.source_service); + } + // Prefer a local device path over a cached download + if (item.source_service === 'DEVICE' && item.local_path) { + existing.local_path = item.local_path; + } + // Keep the best local_path if the existing one is missing + if (!existing.local_path && item.local_path) { + existing.local_path = item.local_path; + } + } else { + byFilename.set(key, { ...item, sources: [item.source_service] }); + } + } + + // Second pass: also deduplicate by source_id for items with different titles + const bySourceId = new Map(); + for (const item of [...byFilename.values(), ...noFilename]) { + const key = item.source_id || item.id || item.local_path || Math.random().toString(); + if (bySourceId.has(key)) { + const existing = bySourceId.get(key); + for (const src of (item.sources || [])) { + if (!existing.sources.includes(src)) existing.sources.push(src); + } + if (!existing.local_path && item.local_path) existing.local_path = item.local_path; + } else { + bySourceId.set(key, item); + } + } + + return Array.from(bySourceId.values()); +}; + const HomeScreen = ({ navigation, route }) => { const userId = route?.params?.userId; const { theme } = useTheme(); @@ -102,11 +158,11 @@ const HomeScreen = ({ navigation, route }) => { console.log('iCloud: placeholder — would fetch photos here'); } - // Deduplicate - const unique = Array.from( - new Map(newMedia.map((m) => [m.source_id || m.id || m.local_path, m])).values() - ); - setAllMedia(unique); + // ─── Merge & cross-reference across services ─── + // Match items across sources by filename so we can show "this photo + // exists on Device AND Google Photos" etc. + const merged = mergeMediaAcrossServices(newMedia); + setAllMedia(merged); } catch (err) { console.error('Error loading media:', err); } finally { @@ -158,13 +214,8 @@ const HomeScreen = ({ navigation, route }) => { setGoogleNextPage(result.nextPageToken || null); setGoogleSessionId(result.sessionId || null); - // Merge into current view - setAllMedia((prev) => { - const combined = [...prev, ...result.mediaItems]; - return Array.from( - new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values() - ); - }); + // Merge into current view with cross-service matching + setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems])); console.log(`Imported ${result.mediaItems.length} photos from Google`); } @@ -188,7 +239,11 @@ const HomeScreen = ({ navigation, route }) => { const filterMedia = () => { let filtered = allMedia; if (selectedFilter !== 'all') { - filtered = allMedia.filter((m) => m.source_service === selectedFilter); + // Check both source_service and the sources array for cross-service items + filtered = allMedia.filter((m) => + m.source_service === selectedFilter || + (m.sources && m.sources.includes(selectedFilter)) + ); } filtered.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0)); setFilteredMedia(filtered); @@ -233,12 +288,7 @@ const HomeScreen = ({ navigation, route }) => { } if (moreMedia.length) { - setAllMedia((prev) => { - const combined = [...prev, ...moreMedia]; - return Array.from( - new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values() - ); - }); + setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...moreMedia])); } } catch (err) { console.error('Error loading more photos:', err); diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js index 4674ee0..8655ce0 100644 --- a/src/services/DatabaseService.js +++ b/src/services/DatabaseService.js @@ -1,11 +1,19 @@ +// noinspection SqlNoDataSourceInspection,SqlResolve + +/** + * Database service — all tables are created at runtime by expo-sqlite. + * The IDE cannot resolve them statically; the SQL warnings are safe to ignore. + */ import * as SQLite from 'expo-sqlite'; import CredentialEncryption from './CredentialEncryption'; +import { logError } from './ErrorHandler'; let db = null; let initPromise = null; const DatabaseService = { - // Open the database and create all tables + // ─── Initialisation ──────────────────────────────────────── + initDatabase: async () => { if (initPromise) return initPromise; @@ -14,12 +22,17 @@ const DatabaseService = { if (db) return true; if (typeof SQLite.openDatabaseAsync !== 'function') { - throw new Error('SQLite openDatabaseAsync API not available'); + logError('DB Init', 'SQLite openDatabaseAsync API not available'); + return false; } db = await SQLite.openDatabaseAsync('PhotosApp.db'); - if (!db) throw new Error('SQLite openDatabaseAsync returned null'); + if (!db) { + logError('DB Init', 'SQLite openDatabaseAsync returned null'); + return false; + } + // Create all tables in one batch await db.execAsync(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -27,10 +40,7 @@ const DatabaseService = { email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - - await db.execAsync(` + ); CREATE TABLE IF NOT EXISTS linked_accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -40,10 +50,7 @@ const DatabaseService = { is_default INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) - ) - `); - - await db.execAsync(` + ); CREATE TABLE IF NOT EXISTS media ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, @@ -58,20 +65,20 @@ const DatabaseService = { synced INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) - ) + ); `); - // Add optional columns (safe migration — errors ignored if columns already exist) - try { await db.execAsync(`ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'`); } catch (_) {} - try { await db.execAsync(`ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'`); } catch (_) {} + // Safe migrations — errors ignored if columns already exist + try { await db.execAsync("ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'"); } catch (_) {} + try { await db.execAsync("ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'"); } catch (_) {} console.log('Database initialized successfully'); return true; } catch (error) { - console.error('Database initialization error:', error); + logError('DB Init', error); db = null; initPromise = null; - throw error; + return false; } })(); @@ -80,12 +87,18 @@ const DatabaseService = { ensureDb: async () => { await DatabaseService.initDatabase(); - if (!db) throw new Error('Database not initialized'); + if (!db) { + logError('DB', 'Database not initialized'); + return false; + } + return true; }, + // ─── Users ───────────────────────────────────────────────── + createUser: async (username, email, password) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return null; const hashedPassword = await CredentialEncryption.hashPassword(password); const result = await db.runAsync( 'INSERT INTO users (username, email, password) VALUES (?, ?, ?)', @@ -93,98 +106,111 @@ const DatabaseService = { ); return result.lastInsertRowId; } catch (error) { - console.error('Error creating user:', error); - throw error; + logError('Create User', error); + return null; } }, - // Authenticate by username or email loginUser: async (identifier, password) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return null; const row = await db.getFirstAsync( 'SELECT * FROM users WHERE username = ? OR email = ?', [identifier, identifier] ); if (!row) return null; - const valid = await CredentialEncryption.verifyPassword(password, row.password); - if (!valid) return null; - - return row; + return valid ? row : null; } catch (error) { - console.error('Error logging in user:', error); - throw error; + logError('Login User', error); + return null; } }, userExists: async (username, email) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return false; const row = await db.getFirstAsync( 'SELECT id FROM users WHERE username = ? OR email = ?', [username, email] ); return !!row; } catch (error) { - console.error('Error checking if user exists:', error); - throw error; + logError('User Exists', error); + return false; } }, getUserById: async (userId) => { try { - await DatabaseService.ensureDb(); - if (!userId && userId !== 0) throw new Error('User ID is required'); + if (!(await DatabaseService.ensureDb())) return null; + if (!userId && userId !== 0) { + logError('Get User', 'User ID is required'); + return null; + } return (await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId])) || null; } catch (error) { - console.error('Error getting user:', error); - throw error; + logError('Get User', error); + return null; } }, - // Link an external service account (Google, iCloud, NAS) to a local user + // ─── Linked Accounts ────────────────────────────────────── + linkAccount: async (userId, serviceType, accountData, isDefault = false) => { try { - await DatabaseService.ensureDb(); - if (!userId) throw new Error('User ID is required'); - if (!serviceType) throw new Error('Service type is required'); - if (!accountData || typeof accountData !== 'object') throw new Error('Account data must be a valid object'); + if (!(await DatabaseService.ensureDb())) return null; + if (!userId || !serviceType || !accountData || typeof accountData !== 'object') { + logError('Link Account', 'userId, serviceType, and accountData object are required'); + return null; + } const encryptedData = CredentialEncryption.encrypt(accountData); - if (!encryptedData) throw new Error('Encryption returned empty data'); + if (!encryptedData) { + logError('Link Account', 'Encryption returned empty data'); + return null; + } // Replace any existing link for the same service type - await db.runAsync('DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?', [userId, serviceType]); - + await db.runAsync( + 'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?', + [userId, serviceType] + ); const result = await db.runAsync( 'INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) VALUES (?, ?, ?, ?)', [userId, serviceType, encryptedData, isDefault ? 1 : 0] ); return result.lastInsertRowId; } catch (error) { - console.error('Error linking account:', error); - throw new Error('Failed to link account: ' + error.message); + logError('Link Account', error); + return null; } }, getLinkedAccounts: async (userId) => { try { - await DatabaseService.ensureDb(); - if (!userId && userId !== 0) throw new Error('User ID is required'); + if (!(await DatabaseService.ensureDb())) return []; + if (!userId && userId !== 0) { + logError('Get Linked Accounts', 'User ID is required'); + return []; + } const rows = await db.getAllAsync( - 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1', [userId] + 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1', + [userId] ); - return rows.map((row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) })); + return rows.map((row) => ({ + ...row, + account_data: CredentialEncryption.decrypt(row.account_data), + })); } catch (error) { - console.error('Error getting linked accounts:', error); - throw error; + logError('Get Linked Accounts', error); + return []; } }, getLinkedAccountByService: async (userId, serviceType) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return null; const row = await db.getFirstAsync( 'SELECT * FROM linked_accounts WHERE user_id = ? AND service_type = ? AND is_enabled = 1', [userId, serviceType] @@ -192,40 +218,83 @@ const DatabaseService = { if (!row) return null; return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) }; } catch (error) { - console.error('Error getting linked account:', error); - throw error; + logError('Get Linked Account By Service', error); + return null; } }, setDefaultService: async (userId, accountId) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return; await db.runAsync('UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?', [userId]); await db.runAsync('UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?', [accountId, userId]); } catch (error) { - console.error('Error setting default service:', error); - throw error; + logError('Set Default Service', error); } }, getDefaultService: async (userId) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return null; const row = await db.getFirstAsync( - 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1', [userId] + 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1', + [userId] ); if (!row) return null; return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) }; } catch (error) { - console.error('Error getting default service:', error); - throw error; + logError('Get Default Service', error); + return null; } }, + toggleAccountEnabled: async (accountId, enabled) => { + try { + if (!(await DatabaseService.ensureDb())) return; + await db.runAsync( + 'UPDATE linked_accounts SET is_enabled = ? WHERE id = ?', + [enabled ? 1 : 0, accountId] + ); + } catch (error) { + logError('Toggle Account', error); + } + }, + + removeLinkedAccount: async (accountId) => { + try { + if (!(await DatabaseService.ensureDb())) return; + await db.runAsync('DELETE FROM linked_accounts WHERE id = ?', [accountId]); + } catch (error) { + logError('Remove Account', error); + } + }, + + getAllLinkedAccounts: async (userId) => { + try { + if (!(await DatabaseService.ensureDb())) return []; + const rows = await db.getAllAsync( + 'SELECT * FROM linked_accounts WHERE user_id = ?', + [userId] + ); + return rows.map((row) => ({ + ...row, + account_data: CredentialEncryption.decrypt(row.account_data), + })); + } catch (error) { + logError('Get All Linked Accounts', error); + return []; + } + }, + + // ─── Media ───────────────────────────────────────────────── + addMedia: async (userId, sourceService, sourceId, title, description, localPath, remotePath, mediaType, createdDate) => { try { - await DatabaseService.ensureDb(); - if (!userId || !sourceService) throw new Error('userId and sourceService are required'); + if (!(await DatabaseService.ensureDb())) return null; + if (!userId || !sourceService) { + logError('Add Media', 'userId and sourceService are required'); + return null; + } const result = await db.runAsync( `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, @@ -234,8 +303,8 @@ const DatabaseService = { ); return result.lastInsertRowId; } catch (error) { - console.error('Error adding media:', error); - throw error; + logError('Add Media', error); + return null; } }, @@ -245,10 +314,10 @@ const DatabaseService = { */ saveMediaItem: async (userId, item) => { try { - await DatabaseService.ensureDb(); - if (!userId || !item.source_service) return; + if (!(await DatabaseService.ensureDb())) return null; + if (!userId || !item?.source_service) return null; - // Check for existing entry to avoid duplicates + // Skip if already exists if (item.source_id) { const existing = await db.getFirstAsync( 'SELECT id FROM media WHERE user_id = ? AND source_id = ?', @@ -266,108 +335,94 @@ const DatabaseService = { ); return result.lastInsertRowId; } catch (error) { - console.error('Error saving media item:', error); + logError('Save Media Item', error); + return null; } }, getMediaByUser: async (userId) => { try { - await DatabaseService.ensureDb(); - if (!userId && userId !== 0) throw new Error('User ID is required'); - return await db.getAllAsync('SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', [userId]); + if (!(await DatabaseService.ensureDb())) return []; + if (!userId && userId !== 0) { + logError('Get Media', 'User ID is required'); + return []; + } + return await db.getAllAsync( + 'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', + [userId] + ); } catch (error) { - console.error('Error getting media:', error); - throw error; + logError('Get Media', error); + return []; } }, getMediaByService: async (userId, serviceType) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return []; return await db.getAllAsync( 'SELECT * FROM media WHERE user_id = ? AND source_service = ? ORDER BY created_date DESC', [userId, serviceType] ); } catch (error) { - console.error('Error getting media by service:', error); - throw error; + logError('Get Media By Service', error); + return []; } }, searchMedia: async (userId, query) => { try { - await DatabaseService.ensureDb(); + if (!(await DatabaseService.ensureDb())) return []; const searchTerm = `%${query}%`; return await db.getAllAsync( 'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC', [userId, searchTerm, searchTerm] ); } catch (error) { - console.error('Error searching media:', error); - throw error; + logError('Search Media', error); + return []; } }, + // ─── Settings ────────────────────────────────────────────── + getAutoBackupServices: async (userId) => { try { - await DatabaseService.ensureDb(); - const row = await db.getFirstAsync('SELECT auto_backup_services FROM users WHERE id = ?', [userId]); + if (!(await DatabaseService.ensureDb())) return []; + const row = await db.getFirstAsync( + 'SELECT auto_backup_services FROM users WHERE id = ?', + [userId] + ); if (!row?.auto_backup_services) return []; return JSON.parse(row.auto_backup_services); } catch (error) { - console.error('Error getting auto backup services:', error); + logError('Get Auto Backup', error); return []; } }, setAutoBackupServices: async (userId, services) => { try { - await DatabaseService.ensureDb(); - await db.runAsync('UPDATE users SET auto_backup_services = ? WHERE id = ?', [JSON.stringify(services), userId]); + if (!(await DatabaseService.ensureDb())) return; + await db.runAsync( + 'UPDATE users SET auto_backup_services = ? WHERE id = ?', + [JSON.stringify(services), userId] + ); } catch (error) { - console.error('Error setting auto backup services:', error); - throw error; + logError('Set Auto Backup', error); } }, - toggleAccountEnabled: async (accountId, enabled) => { - try { - await DatabaseService.ensureDb(); - await db.runAsync('UPDATE linked_accounts SET is_enabled = ? WHERE id = ?', [enabled ? 1 : 0, accountId]); - } catch (error) { - console.error('Error toggling account:', error); - throw error; - } - }, - - removeLinkedAccount: async (accountId) => { - try { - await DatabaseService.ensureDb(); - await db.runAsync('DELETE FROM linked_accounts WHERE id = ?', [accountId]); - } catch (error) { - console.error('Error removing account:', error); - throw error; - } - }, - - getAllLinkedAccounts: async (userId) => { - try { - await DatabaseService.ensureDb(); - const rows = await db.getAllAsync('SELECT * FROM linked_accounts WHERE user_id = ?', [userId]); - return rows.map((row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) })); - } catch (error) { - console.error('Error getting all linked accounts:', error); - throw error; - } - }, + // ─── Lifecycle ───────────────────────────────────────────── closeDatabase: async () => { try { if (db?.closeAsync) await db.closeAsync(); + } catch (error) { + logError('Close DB', error); + } finally { db = null; initPromise = null; - } catch (error) { - console.error('Error closing database:', error); } }, }; From 99c0e1186ceedb26d24445e230705e9d97b510f8 Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Wed, 25 Feb 2026 01:17:32 +0000 Subject: [PATCH 5/6] feat: refactor Google Auth handling and improve media file management with enhanced error logging --- src/AUTH/GoogleAUTH.js | 119 ++++++++++++++++++++++++++---------- src/components/PhotoGrid.js | 7 ++- src/screens/HomeScreen.js | 19 +++++- 3 files changed, 110 insertions(+), 35 deletions(-) diff --git a/src/AUTH/GoogleAUTH.js b/src/AUTH/GoogleAUTH.js index c8bb260..ae7f206 100644 --- a/src/AUTH/GoogleAUTH.js +++ b/src/AUTH/GoogleAUTH.js @@ -1,7 +1,7 @@ import * as AuthSession from 'expo-auth-session'; import * as WebBrowser from 'expo-web-browser'; import * as Application from 'expo-application'; -import * as FileSystem from 'expo-file-system'; +import { File, Directory, Paths } from 'expo-file-system'; import { AuthServiceBase } from './AuthServiceBase'; import { logError } from '../services/ErrorHandler'; @@ -30,7 +30,7 @@ const SCOPES = [ const PICKER_API = 'https://photospicker.googleapis.com/v1'; -const CACHE_DIR = `${FileSystem.cacheDirectory}google_photos/`; +const CACHE_DIR = new Directory(Paths.cache, 'google_photos'); const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`; @@ -274,32 +274,55 @@ class GoogleAUTH extends AuthServiceBase { return { mediaItems: [], nextPageToken: null }; } - return await res.json(); + const data = await res.json(); + + // Debug: log raw response structure + if (data.mediaItems?.length) { + console.log('Picker raw item[0]:', JSON.stringify(data.mediaItems[0], null, 2)); + } else { + console.log('Picker returned no mediaItems. Keys:', Object.keys(data)); + } + + return data; } /** * Normalise Picker API media items to the app's standard format. * - * NOTE: The Picker API's baseUrl requires an Authorization header to - * fetch the image. We store it in `remote_path` / `picker_base_url` - * so the caller can download it to a local cache file for rendering. + * The Picker API response shape per item: + * { id, mediaFile: { baseUrl, mimeType, filename, mediaFileMetadata: { ... } } } + * + * baseUrl requires an Authorization header to fetch the actual image bytes. */ _normalisePickerItems(items) { - return (items || []).map((item) => { - const file = item.mediaFile || {}; - const isVideo = file.mimeType?.startsWith('video'); + if (!items?.length) return []; + + return items.map((item, idx) => { + // The item may have mediaFile nested, or may be flat + const file = item.mediaFile || item; + const baseUrl = file.baseUrl || item.baseUrl || null; + const filename = file.filename || item.filename || 'Untitled'; + const mimeType = file.mimeType || item.mimeType || null; + const isVideo = mimeType?.startsWith('video'); + const creationTime = file.mediaFileMetadata?.creationTime + || item.mediaFileMetadata?.creationTime + || null; + + if (idx === 0) { + console.log(`Normalising item[0]: id=${item.id}, baseUrl=${baseUrl?.substring(0, 60)}…, filename=${filename}, mime=${mimeType}`); + } + return { source_service: 'GOOGLE_PHOTOS', - source_id: item.id || file.id, - title: file.filename || 'Untitled', + source_id: item.id || file.id || `picker_${Date.now()}_${idx}`, + title: filename, description: '', - // local_path is null until we download it with the auth token local_path: null, - remote_path: file.baseUrl || '', - picker_base_url: file.baseUrl || null, + remote_path: baseUrl || '', + picker_base_url: baseUrl, media_type: isVideo ? 'video' : 'photo', - mime_type: file.mimeType || null, - created_date: file.mediaFileMetadata?.creationTime || null, + mime_type: mimeType, + created_date: creationTime, }; }); } @@ -318,9 +341,18 @@ class GoogleAUTH extends AuthServiceBase { const token = await this.getAccessToken(); // Ensure cache directory exists - const dirInfo = await FileSystem.getInfoAsync(CACHE_DIR); - if (!dirInfo.exists) { - await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true }); + if (!CACHE_DIR.exists) { + CACHE_DIR.create({ intermediates: true }); + } else { + // Clean up stale files from previous failed downloads (< 1KB = likely error pages) + try { + const entries = CACHE_DIR.list(); + for (const entry of entries) { + if (entry instanceof File && entry.size < 1024) { + entry.delete(); + } + } + } catch (_) { /* ignore cleanup errors */ } } const results = []; @@ -331,31 +363,52 @@ class GoogleAUTH extends AuthServiceBase { } try { - // Use the source_id as a safe filename const safeId = (item.source_id || 'photo').replace(/[^a-zA-Z0-9_-]/g, '_'); const ext = item.mime_type?.includes('png') ? '.png' : item.mime_type?.includes('gif') ? '.gif' : item.mime_type?.includes('webp') ? '.webp' : '.jpg'; - const localUri = `${CACHE_DIR}${safeId}${ext}`; + const destFile = new File(CACHE_DIR, `${safeId}${ext}`); - // Skip download if already cached - const fileInfo = await FileSystem.getInfoAsync(localUri); - if (fileInfo.exists && fileInfo.size > 0) { - results.push({ ...item, local_path: localUri }); + // Skip download if already cached and non-empty + if (destFile.exists && destFile.size > 1024) { + results.push({ ...item, local_path: destFile.uri }); continue; } - // Download with auth header — append =d to get the full-res download - const downloadUrl = `${item.picker_base_url}=d`; - const download = await FileSystem.downloadAsync(downloadUrl, localUri, { - headers: { Authorization: `Bearer ${token}` }, - }); + // The Picker API baseUrl is a direct download link when used with + // the Authorization header. No suffix needed. + console.log(`Downloading: ${item.title} (${item.picker_base_url.substring(0, 80)}…)`); - if (download.status === 200) { - results.push({ ...item, local_path: download.uri }); + const downloaded = await File.downloadFileAsync( + item.picker_base_url, + destFile, + { headers: { Authorization: `Bearer ${token}` }, idempotent: true } + ); + + console.log(` → uri=${downloaded.uri}, size=${downloaded.size}`); + + if (downloaded.exists && downloaded.size > 1024) { + results.push({ ...item, local_path: downloaded.uri }); + } else if (downloaded.exists && downloaded.size > 0) { + // Suspiciously small — check if it's an error page + try { + const peek = await downloaded.text(); + if (peek.startsWith('<') || peek.startsWith('{')) { + logError('Download Picker Photo', + `Got error page for ${item.title}: ${peek.substring(0, 150)}`); + downloaded.delete(); + results.push(item); // no local_path + } else { + results.push({ ...item, local_path: downloaded.uri }); + } + } catch (_) { + // Can't read as text = probably binary = probably an image + results.push({ ...item, local_path: downloaded.uri }); + } } else { - logError('Download Picker Photo', `HTTP ${download.status} for ${item.title}`); + logError('Download Picker Photo', `Empty file for ${item.title}`); + if (downloaded.exists) downloaded.delete(); results.push(item); } } catch (err) { diff --git a/src/components/PhotoGrid.js b/src/components/PhotoGrid.js index 4b56f3a..c7b252e 100644 --- a/src/components/PhotoGrid.js +++ b/src/components/PhotoGrid.js @@ -54,7 +54,12 @@ const PhotoGrid = ({ activeOpacity={0.8} > {item.local_path ? ( - + console.warn(`Image load failed [${item.source_service}]: ${item.local_path?.substring(0, 80)}`, e.nativeEvent?.error)} + /> ) : ( 📷 diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index 49099f0..a615533 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -8,6 +8,7 @@ import DatabaseService from '../services/DatabaseService'; import DeviceMediaService from '../services/DeviceMediaService'; import GoogleAUTH from '../AUTH/GoogleAUTH'; import NASAUTH from '../AUTH/NASAUTH'; +import { File } from 'expo-file-system'; import TopBar from '../components/TopBar'; import PhotoGrid from '../components/PhotoGrid'; import MediaViewer from '../components/MediaViewer'; @@ -132,7 +133,23 @@ const HomeScreen = ({ navigation, route }) => { // ─── Previously imported Google Photos (stored in DB) ─── try { const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS'); - if (googleMedia?.length) newMedia.push(...googleMedia); + if (googleMedia?.length) { + // Validate cached files still exist — clear stale paths + const validated = googleMedia.map((item) => { + if (item.local_path) { + try { + const f = new File(item.local_path); + if (!f.exists || f.size === 0) { + return { ...item, local_path: null }; + } + } catch (_) { + return { ...item, local_path: null }; + } + } + return item; + }); + newMedia.push(...validated); + } } catch (err) { console.error('DB Google media fetch error:', err); } From b0611e4b20afa5f13b8b8903dce5a33bf4ce4a96 Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Wed, 25 Feb 2026 02:20:46 +0000 Subject: [PATCH 6/6] feat: add iOS client ID for Google Auth and update app.json with bundle identifier --- app.json | 5 +++-- src/AUTH/GoogleAUTH.js | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app.json b/app.json index e04971b..accf8a7 100644 --- a/app.json +++ b/app.json @@ -13,7 +13,8 @@ "backgroundColor": "#ffffff" }, "ios": { - "supportsTablet": true + "supportsTablet": true, + "bundleIdentifier": "com.bobthebob.massphotoapp" }, "android": { "package": "com.bobthebob.massphotoapp", @@ -35,4 +36,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/AUTH/GoogleAUTH.js b/src/AUTH/GoogleAUTH.js index ae7f206..2b3e541 100644 --- a/src/AUTH/GoogleAUTH.js +++ b/src/AUTH/GoogleAUTH.js @@ -11,6 +11,7 @@ WebBrowser.maybeCompleteAuthSession(); // with package name com.bobthebob.massphotoapp and the SHA-1 from // `eas credentials --platform android`. const ANDROID_CLIENT_ID = '313090284964-018uf4v1bmmrra09lvmpnuug2qs09047.apps.googleusercontent.com'; +const IOS_CLIENT_ID = '313090284964-luka809nuc66bb4cg4gameovtqg8uoj1.apps.googleusercontent.com'; // Optional, just in case being kept here. const GOOGLE_DISCOVERY = { authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',