diff --git a/TEST/testGooglePhotosAPI.js b/TEST/testGooglePhotosAPI.js index 3a4074a..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,61 +29,87 @@ 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...'); 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`); } - // 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) 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 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) 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(); - } catch (err) { - console.log(` Failed: ${err.message}\n`); + // 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`); + } } console.log('--- Tests complete ---\n'); 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/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 1cb760d..2b3e541 100644 --- a/src/AUTH/GoogleAUTH.js +++ b/src/AUTH/GoogleAUTH.js @@ -1,15 +1,17 @@ import * as AuthSession from 'expo-auth-session'; import * as WebBrowser from 'expo-web-browser'; import * as Application from 'expo-application'; +import { File, Directory, Paths } from 'expo-file-system'; import { AuthServiceBase } from './AuthServiceBase'; +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 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', @@ -17,17 +19,20 @@ 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', ]; +const PICKER_API = 'https://photospicker.googleapis.com/v1'; + +const CACHE_DIR = new Directory(Paths.cache, 'google_photos'); -// 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 REDIRECT_URI = `${Application.applicationId}:/oauthredirect`; class GoogleAUTH extends AuthServiceBase { @@ -37,110 +42,85 @@ class GoogleAUTH extends AuthServiceBase { this.refreshToken = null; this.expiresAt = null; this.email = null; - this.idToken = null; } + // ───────────────────── Authentication ───────────────────── + 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; + } + + 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 +142,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 +174,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 }; } @@ -210,154 +188,324 @@ 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. - // An empty filters body returns ALL photos, ordered by creation time. - 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 || ''; - - console.error( - `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'); - - 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) { - console.error('Get photos error:', 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 }; } + + 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; } - async getAlbums(pageSize = 50, pageToken = null) { - try { - const token = await this.getAccessToken(); - const params = new URLSearchParams({ pageSize: String(pageSize) }); - if (pageToken) params.append('pageToken', pageToken); + /** + * Normalise Picker API media items to the app's standard format. + * + * 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) { + if (!items?.length) return []; - const response = await fetch( - `https://photoslibrary.googleapis.com/v1/albums?${params.toString()}`, - { headers: { Authorization: `Bearer ${token}` } } - ); + 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 (!response.ok) { - const errorBody = await response.text(); - console.error(`Google Albums API error ${response.status}:`, errorBody); - return { albums: [], nextPageToken: null }; + if (idx === 0) { + console.log(`Normalising item[0]: id=${item.id}, baseUrl=${baseUrl?.substring(0, 60)}…, filename=${filename}, mime=${mimeType}`); } - const data = await response.json(); - return { albums: data.albums || [], nextPageToken: data.nextPageToken || null }; + + return { + source_service: 'GOOGLE_PHOTOS', + source_id: item.id || file.id || `picker_${Date.now()}_${idx}`, + title: filename, + description: '', + local_path: null, + remote_path: baseUrl || '', + picker_base_url: baseUrl, + media_type: isVideo ? 'video' : 'photo', + mime_type: mimeType, + created_date: creationTime, + }; + }); + } + + /** + * 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 + 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 = []; + for (const item of items) { + if (!item.picker_base_url) { + results.push(item); + continue; + } + + try { + 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 destFile = new File(CACHE_DIR, `${safeId}${ext}`); + + // Skip download if already cached and non-empty + if (destFile.exists && destFile.size > 1024) { + results.push({ ...item, local_path: destFile.uri }); + continue; + } + + // 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)}…)`); + + 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', `Empty file for ${item.title}`); + if (downloaded.exists) downloaded.delete(); + 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. + * + * @returns {{ mediaItems, nextPageToken, sessionId, error? }} + */ + async pickPhotos() { + try { + // 1. Create session + console.log('Creating Google Photos Picker session…'); + const session = await this._createPickerSession(); + + if (session?.error) { + return { + mediaItems: [], nextPageToken: null, sessionId: null, + error: { code: session.error.code, message: session.error.message }, + }; + } + 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 and download to local cache + const result = await this._fetchPickerMediaItems(session.id, 100, null); + let mediaItems = this._normalisePickerItems(result.mediaItems); + + 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, + nextPageToken: result.nextPageToken || null, + sessionId: session.id, + }; } catch (error) { - console.error('Get albums error:', error); - return { albums: [], nextPageToken: null }; + logError('Pick Photos', error); + return { mediaItems: [], nextPageToken: null, sessionId: 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') { + /** + * Fetch more items from an existing Picker session (pagination). + */ + async getMorePhotos(pageSize = 100, pageToken, sessionId) { try { - const token = await this.getAccessToken(); + if (!sessionId || !pageToken) { + return { mediaItems: [], nextPageToken: null, sessionId }; + } - 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()), - }); + const result = await this._fetchPickerMediaItems(sessionId, pageSize, pageToken); + let mediaItems = this._normalisePickerItems(result.mediaItems); + mediaItems = await this.downloadPickerPhotos(mediaItems); - if (!uploadResponse.ok) throw new Error('Upload bytes failed'); - 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 } }], - }), - } - ); - - if (!createResponse.ok) throw new Error('Create media item failed'); - return await createResponse.json(); + return { + mediaItems, + nextPageToken: result.nextPageToken || null, + sessionId, + }; } catch (error) { - console.error('Upload photo error:', error); - throw error; + logError('Get More Photos', error); + return { mediaItems: [], nextPageToken: null, sessionId }; } } } diff --git a/src/components/MediaViewer.js b/src/components/MediaViewer.js index c4ba5c8..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] - ); + } + }; 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..c7b252e 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 }) => ( {item.local_path ? ( - + console.warn(`Image load failed [${item.source_service}]: ${item.local_path?.substring(0, 80)}`, e.nativeEvent?.error)} + /> ) : ( 📷 @@ -41,9 +70,7 @@ const PhotoGrid = ({ ▶ )} - - {getServiceIcon(item.source_service)} - + {renderSourceBadges(item)} ); @@ -82,6 +109,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/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/screens/HomeScreen.js b/src/screens/HomeScreen.js index c97fbe8..a615533 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -6,12 +6,71 @@ 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 { File } from 'expo-file-system'; 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'; +/** + * 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(); @@ -26,7 +85,10 @@ 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); + const [importing, setImporting] = useState(false); useFocusEffect( useCallback(() => { @@ -56,7 +118,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 +130,30 @@ const HomeScreen = ({ navigation, route }) => { console.error('Device media fetch error:', err); } + // ─── Previously imported Google Photos (stored in DB) ─── + try { + const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS'); + 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); + } + // ─── NAS fetch ─── const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS'); if (nasAccount) { @@ -89,10 +175,11 @@ const HomeScreen = ({ navigation, route }) => { console.log('iCloud: placeholder — would fetch photos here'); } - 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 { @@ -101,30 +188,125 @@ 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 with cross-service matching + setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems])); + + 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') { - 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); }; const loadMorePhotos = async () => { - if (loadingMore || !deviceHasMore) return; + if (loadingMore) return; + const canLoadDevice = deviceHasMore; + const canLoadGoogle = !!googleNextPage && !!googleSessionId; + if (!canLoadDevice && !canLoadGoogle) return; + setLoadingMore(true); try { - const result = await DeviceMediaService.getPhotos(100, deviceEndCursor); - if (result.mediaItems.length) { - 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() - ); - }); + const moreMedia = []; + + if (canLoadDevice) { + const result = await DeviceMediaService.getPhotos(100, deviceEndCursor); + if (result.mediaItems.length) moreMedia.push(...result.mediaItems); + setDeviceEndCursor(result.endCursor); + setDeviceHasMore(result.hasNextPage); + } + + 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.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); + } + } + + if (moreMedia.length) { + setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...moreMedia])); } - setDeviceEndCursor(result.endCursor); - setDeviceHasMore(result.hasNextPage); } catch (err) { console.error('Error loading more photos:', err); } finally { @@ -135,6 +317,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 ( @@ -188,12 +372,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'} { 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,109 +303,126 @@ const DatabaseService = { ); return result.lastInsertRowId; } catch (error) { - console.error('Error adding media:', error); - throw error; + logError('Add Media', error); + return null; + } + }, + + /** + * Save a normalised media item object to the DB. + * Skips duplicates (same user_id + source_id). + */ + saveMediaItem: async (userId, item) => { + try { + if (!(await DatabaseService.ensureDb())) return null; + if (!userId || !item?.source_service) return null; + + // Skip if already exists + 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) { + 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); } }, }; 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 }; +