feat: migrate to Google Photos Picker API and update related documentation due to normal library api being depricated (kept for added media from app)

This commit is contained in:
bobbert committed 2026-02-24 23:55:04 +00:00
1 parent f6c61ab829
commit 5c096fbab4
5 files changed
+363 -201

No files matched your search

+57 -41
View File
@@ -1,27 +1,26 @@
// Google Photos API Test // Google Photos Picker API Test
// //
// Usage: // Usage:
// node TEST/testGooglePhotosAPI.js <ACCESS_TOKEN> // node TEST/testGooglePhotosAPI.js <ACCESS_TOKEN>
// GOOGLE_ACCESS_TOKEN=ya29.xxx 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): // Getting a token from OAuth Playground (https://developers.google.com/oauthplayground):
// 1. Click the gear icon → check "Use your own OAuth credentials" // 1. Click the gear icon → check "Use your own OAuth credentials"
// 2. Enter your Client ID and Client Secret from Google Cloud Console // 2. Enter your Client ID and Client Secret from Google Cloud Console
// 3. In the left panel select ALL of these scopes: // 3. In "Input your own scopes" enter:
// https://www.googleapis.com/auth/photoslibrary // https://www.googleapis.com/auth/photospicker.mediaitems.readonly
// https://www.googleapis.com/auth/photoslibrary.appendonly
// https://www.googleapis.com/auth/userinfo.email
// https://www.googleapis.com/auth/userinfo.profile
// 4. Click "Authorize APIs" and sign in with your test Google account // 4. Click "Authorize APIs" and sign in with your test Google account
// 5. Click "Exchange authorization code for tokens" // 5. Click "Exchange authorization code for tokens"
// 6. Copy the Access token (starts with ya29.) and pass it here // 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'; const USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
function delay(ms) { return new Promise((r) => setTimeout(r, ms)); }
async function main() { async function main() {
const token = process.argv[2] || process.env.GOOGLE_ACCESS_TOKEN; const token = process.argv[2] || process.env.GOOGLE_ACCESS_TOKEN;
@@ -30,13 +29,13 @@ async function main() {
console.error('Usage: node TEST/testGooglePhotosAPI.js <ACCESS_TOKEN>'); console.error('Usage: node TEST/testGooglePhotosAPI.js <ACCESS_TOKEN>');
console.error('Or set: GOOGLE_ACCESS_TOKEN=ya29.xxx 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('\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); process.exit(1);
} }
const headers = { Authorization: `Bearer ${token}` }; 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 // Test 1 — User info
console.log('[1] Fetching user info...'); console.log('[1] Fetching user info...');
@@ -52,48 +51,65 @@ async function main() {
console.log(` Failed: ${err.message}\n`); console.log(` Failed: ${err.message}\n`);
} }
// Test 2 — Search media items (replaces deprecated GET /mediaItems) // Test 2 — Create Picker session
console.log('[2] Searching media items (first 10)...'); console.log('[2] Creating Picker session...');
let sessionId = null;
try { try {
const res = await fetch(`${API_BASE}/mediaItems:search`, { const res = await fetch(`${PICKER_BASE}/sessions`, {
method: 'POST', method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' }, headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ pageSize: 10 }), body: JSON.stringify({}),
}); });
if (!res.ok) { if (!res.ok) {
console.log(` Failed: HTTP ${res.status}: ${await res.text()}\n`); console.log(` Failed: HTTP ${res.status}: ${await res.text()}\n`);
} else { } else {
const data = await res.json(); const session = await res.json();
const items = data.mediaItems || []; sessionId = session.id;
console.log(` Found ${items.length} items`); console.log(` Session created: ${sessionId}`);
items.slice(0, 5).forEach((item, i) => { console.log(` mediaItemsSet: ${session.mediaItemsSet}\n`);
console.log(` ${i + 1}. ${item.filename} (${item.mimeType})`);
}); // Wait for session to be ready
if (items.length > 5) console.log(` ... and ${items.length - 5} more`); if (!session.mediaItemsSet) {
if (data.nextPageToken) console.log(' More pages available'); console.log(' Waiting for session to become ready...');
console.log(); 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) { } catch (err) {
console.log(` Failed: ${err.message}\n`); console.log(` Failed: ${err.message}\n`);
} }
// Test 3 — List albums // Test 3 — List media items from session
console.log('[3] Listing albums...'); if (sessionId) {
try { console.log('[3] Fetching media items from session...');
const res = await fetch(`${API_BASE}/albums?pageSize=10`, { headers }); try {
if (!res.ok) { const params = new URLSearchParams({ sessionId, pageSize: '10' });
console.log(` Failed: HTTP ${res.status}: ${await res.text()}\n`); const res = await fetch(`${PICKER_BASE}/mediaItems?${params.toString()}`, { headers });
} else { if (!res.ok) {
const data = await res.json(); console.log(` Failed: HTTP ${res.status}: ${await res.text()}\n`);
const albums = data.albums || []; } else {
console.log(` Found ${albums.length} albums`); const data = await res.json();
albums.slice(0, 5).forEach((album, i) => { const items = data.mediaItems || [];
console.log(` ${i + 1}. "${album.title}" - ${album.mediaItemsCount || 0} items`); console.log(` Found ${items.length} items`);
}); items.slice(0, 5).forEach((item, i) => {
console.log(); 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'); console.log('--- Tests complete ---\n');
+6 -6
View File
@@ -5,10 +5,11 @@
2. Click **Select a project** → **New Project** 2. Click **Select a project** → **New Project**
3. Name it and click **Create** 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** 1. Go to **APIs & Services** → **Library**
2. Search for **Photos Library API** → **Enable** it 2. Search for **Photos Picker API** → **Enable** it
3. ⚠️ This step is **required** — without it, all Photos API calls return 403 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 ## Step 3: Configure the OAuth Consent Screen
1. Go to **APIs & Services** → **OAuth consent screen** 1. Go to **APIs & Services** → **OAuth consent screen**
@@ -19,13 +20,12 @@
- **Developer contact**: your email - **Developer contact**: your email
4. **Scopes** → Add or Remove Scopes → add these: 4. **Scopes** → Add or Remove Scopes → add these:
- `openid` - `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.email`
- `https://www.googleapis.com/auth/userinfo.profile` - `https://www.googleapis.com/auth/userinfo.profile`
5. **Test users** → **Add your Google account email address** 5. **Test users** → **Add your Google account email address**
- ⚠️ This is **critical** — if your email is not listed as a test user, - ⚠️ This is **critical** — if your email is not listed as a test user,
Google will silently strip the Photos scope from the granted token, Google will silently strip the Photos Picker scope from the granted token.
causing a 403 "insufficient authentication scopes" error.
## Step 4: Create an Android OAuth Client ID ## Step 4: Create an Android OAuth Client ID
1. Go to **APIs & Services** → **Credentials** 1. Go to **APIs & Services** → **Credentials**
+16 -15
View File
@@ -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 GooglePhotosAPI = (getAccessToken) => {
const PICKER_ROOT = 'https://photospicker.googleapis.com/v1';
const callFetch = async (endpoint, method = 'GET', dataObj = null) => { const callFetch = async (endpoint, method = 'GET', dataObj = null) => {
const token = await getAccessToken(); const token = await getAccessToken();
if (!token) return { isSuccess: false, message: 'Missing access token' }; if (!token) return { isSuccess: false, message: 'Missing access token' };
let requestObj = { const requestObj = {
method, method,
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}; };
@@ -25,21 +28,19 @@ const GooglePhotosAPI = (getAccessToken) => {
} }
}; };
const API_ROOT = 'https://photoslibrary.googleapis.com/v1';
return { return {
listMediaItems: (pageSize = 25, pageToken) => { // Create a Picker session — must be called before listing items
const body = { pageSize }; createSession: () => callFetch(`${PICKER_ROOT}/sessions`, 'POST', {}),
if (pageToken) body.pageToken = pageToken;
return callFetch(`${API_ROOT}/mediaItems:search`, 'POST', body); // 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 }),
}; };
}; };
+181 -132
View File
@@ -6,10 +6,9 @@ import { logError } from '../services/ErrorHandler';
WebBrowser.maybeCompleteAuthSession(); WebBrowser.maybeCompleteAuthSession();
// Android client ID — used for both auth requests and token exchange. // Android client ID — created in Google Cloud Console as "Android" type
// Created in Google Cloud Console as "Android" type with package name // with package name com.bobthebob.massphotoapp and the SHA-1 from
// com.bobthebob.massphotoapp and the SHA-1 from `eas credentials --platform android`. // `eas credentials --platform android`.
// Android clients are public (no client_secret needed) — PKCE verifies instead.
const ANDROID_CLIENT_ID = '313090284964-018uf4v1bmmrra09lvmpnuug2qs09047.apps.googleusercontent.com'; const ANDROID_CLIENT_ID = '313090284964-018uf4v1bmmrra09lvmpnuug2qs09047.apps.googleusercontent.com';
const GOOGLE_DISCOVERY = { const GOOGLE_DISCOVERY = {
@@ -18,16 +17,18 @@ const GOOGLE_DISCOVERY = {
revocationEndpoint: 'https://oauth2.googleapis.com/revoke', 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 = [ const SCOPES = [
'openid', 'openid',
'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile', '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 const PICKER_API = 'https://photospicker.googleapis.com/v1';
// expo-auth-session/providers/google generates for installed Android apps.
// Result: com.bobthebob.massphotoapp:/oauthredirect
const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`; const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`;
class GoogleAUTH extends AuthServiceBase { class GoogleAUTH extends AuthServiceBase {
@@ -39,6 +40,8 @@ class GoogleAUTH extends AuthServiceBase {
this.email = null; this.email = null;
} }
// ───────────────────── Authentication ─────────────────────
async authenticate() { async authenticate() {
if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') { if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') {
console.warn('Google Auth: no Android client ID — using placeholder mode'); console.warn('Google Auth: no Android client ID — using placeholder mode');
@@ -77,8 +80,6 @@ class GoogleAUTH extends AuthServiceBase {
throw err; throw err;
} }
// Exchange authorization code for tokens.
// Android clients are public — PKCE code_verifier secures the exchange.
const tokenBody = new URLSearchParams({ const tokenBody = new URLSearchParams({
client_id: ANDROID_CLIENT_ID, client_id: ANDROID_CLIENT_ID,
code: result.params.code, code: result.params.code,
@@ -183,150 +184,198 @@ class GoogleAUTH extends AuthServiceBase {
return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' }; return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' };
} }
async getPhotos(pageSize = 50, pageToken = null) { // ───────────────── Photos Picker API ──────────────────
try { //
const token = await this.getAccessToken(); // 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. * Create a new Picker session.
const body = { pageSize }; * Returns the full session object { id, pickerUri, mediaItemsSet, ... }
if (pageToken) body.pageToken = pageToken; * 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'; if (!res.ok) {
const response = await fetch(url, { const body = await res.text();
method: 'POST', logError('Picker Session Create', `${res.status}: ${body}`);
headers: { return { error: { code: res.status, message: body } };
Authorization: `Bearer ${token}`, }
'Content-Type': 'application/json',
}, return await res.json();
body: JSON.stringify(body), }
/**
* 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) { if (!res.ok) {
let errorBody = ''; logError('Picker Session Poll', `${res.status}: ${await res.text()}`);
let errorJson = null; return 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 };
} }
const data = await response.json(); const session = await res.json();
return { if (session.mediaItemsSet) return session;
mediaItems: (data.mediaItems || []).map((item) => ({
source_service: 'GOOGLE_PHOTOS', await new Promise((resolve) => setTimeout(resolve, delayMs));
source_id: item.id, }
title: item.filename || 'Untitled',
description: item.description || '', console.warn('Picker session timed out waiting for user selection');
local_path: item.baseUrl ? `${item.baseUrl}=w400-h400` : null, return null;
remote_path: item.productUrl || '', }
media_type: item.mimeType?.startsWith('video') ? 'video' : 'photo',
created_date: item.mediaMetadata?.creationTime || null, /**
})), * Fetch media items from a ready Picker session.
nextPageToken: data.nextPageToken || null, */
}; async _fetchPickerMediaItems(sessionId, pageSize = 100, pageToken = null) {
} catch (error) { const token = await this.getAccessToken();
logError('Get Photos', error); 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 { 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 { try {
const token = await this.getAccessToken(); // 1. Create session
const params = new URLSearchParams({ pageSize: String(pageSize) }); console.log('Creating Google Photos Picker session…');
if (pageToken) params.append('pageToken', pageToken); const session = await this._createPickerSession();
const response = await fetch( if (session?.error) {
`https://photoslibrary.googleapis.com/v1/albums?${params.toString()}`, return {
{ headers: { Authorization: `Bearer ${token}` } } mediaItems: [], nextPageToken: null, sessionId: null,
); error: { code: session.error.code, message: session.error.message },
};
if (!response.ok) {
const errorBody = await response.text();
logError('Google Albums API', `${response.status}: ${errorBody}`);
return { albums: [], nextPageToken: null };
} }
const data = await response.json(); if (!session?.id || !session?.pickerUri) {
return { albums: data.albums || [], nextPageToken: data.nextPageToken || null }; 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) { } catch (error) {
logError('Get Albums', error); logError('Pick Photos', error);
return { albums: [], nextPageToken: null }; 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 { 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()),
});
if (!uploadResponse.ok) {
logError('Upload Photo', 'Upload bytes failed: HTTP ' + uploadResponse.status);
return null;
} }
const uploadToken = await uploadResponse.text();
const createResponse = await fetch( const result = await this._fetchPickerMediaItems(sessionId, pageSize, pageToken);
'https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate', const mediaItems = this._normalisePickerItems(result.mediaItems);
{
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
newMediaItems: [{ simpleMediaItem: { uploadToken, fileName: filename } }],
}),
}
);
if (!createResponse.ok) { return {
logError('Upload Photo', 'Create media item failed: HTTP ' + createResponse.status); mediaItems,
return null; nextPageToken: result.nextPageToken || null,
} sessionId,
return await createResponse.json(); };
} catch (error) { } catch (error) {
logError('Upload Photo', error); logError('Get More Photos', error);
return null; return { mediaItems: [], nextPageToken: null, sessionId };
} }
} }
} }
+103 -7
View File
@@ -6,6 +6,7 @@ import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext'; import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService'; import DatabaseService from '../services/DatabaseService';
import DeviceMediaService from '../services/DeviceMediaService'; import DeviceMediaService from '../services/DeviceMediaService';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import NASAUTH from '../AUTH/NASAUTH'; import NASAUTH from '../AUTH/NASAUTH';
import TopBar from '../components/TopBar'; import TopBar from '../components/TopBar';
import PhotoGrid from '../components/PhotoGrid'; import PhotoGrid from '../components/PhotoGrid';
@@ -26,6 +27,8 @@ const HomeScreen = ({ navigation, route }) => {
const [viewerVisible, setViewerVisible] = useState(false); const [viewerVisible, setViewerVisible] = useState(false);
const [deviceEndCursor, setDeviceEndCursor] = useState(null); const [deviceEndCursor, setDeviceEndCursor] = useState(null);
const [deviceHasMore, setDeviceHasMore] = useState(false); const [deviceHasMore, setDeviceHasMore] = useState(false);
const [googleNextPage, setGoogleNextPage] = useState(null);
const [googleSessionId, setGoogleSessionId] = useState(null);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
useFocusEffect( useFocusEffect(
@@ -56,7 +59,7 @@ const HomeScreen = ({ navigation, route }) => {
try { try {
const newMedia = []; const newMedia = [];
// ─── Device photos (camera roll + Google Photos synced images) ─── // ─── Device photos (local camera roll, screenshots, etc.) ───
try { try {
const deviceResult = await DeviceMediaService.getPhotos(100); const deviceResult = await DeviceMediaService.getPhotos(100);
if (deviceResult.mediaItems.length) { if (deviceResult.mediaItems.length) {
@@ -68,6 +71,59 @@ const HomeScreen = ({ navigation, route }) => {
console.error('Device media fetch error:', err); 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 ─── // ─── NAS fetch ───
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS'); const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
if (nasAccount) { if (nasAccount) {
@@ -89,6 +145,7 @@ const HomeScreen = ({ navigation, route }) => {
console.log('iCloud: placeholder — would fetch photos here'); 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( const unique = Array.from(
new Map(newMedia.map((m) => [m.source_id || m.id || m.local_path, m])).values() 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 () => { 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); setLoadingMore(true);
try { try {
const result = await DeviceMediaService.getPhotos(100, deviceEndCursor); const moreMedia = [];
if (result.mediaItems.length) {
// 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) => { setAllMedia((prev) => {
const combined = [...prev, ...result.mediaItems]; const combined = [...prev, ...moreMedia];
return Array.from( return Array.from(
new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values() new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values()
); );
}); });
} }
setDeviceEndCursor(result.endCursor);
setDeviceHasMore(result.hasNextPage);
} catch (err) { } catch (err) {
console.error('Error loading more photos:', err); console.error('Error loading more photos:', err);
} finally { } finally {