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 {