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

+181 -132
View File
@@ -6,10 +6,9 @@ import { logError } from '../services/ErrorHandler';
WebBrowser.maybeCompleteAuthSession();
// Android client ID — used for both auth requests and token exchange.
// Created in Google Cloud Console as "Android" type with package name
// com.bobthebob.massphotoapp and the SHA-1 from `eas credentials --platform android`.
// Android clients are public (no client_secret needed) — PKCE verifies instead.
// Android client ID — created in Google Cloud Console as "Android" type
// with package name com.bobthebob.massphotoapp and the SHA-1 from
// `eas credentials --platform android`.
const ANDROID_CLIENT_ID = '313090284964-018uf4v1bmmrra09lvmpnuug2qs09047.apps.googleusercontent.com';
const GOOGLE_DISCOVERY = {
@@ -18,16 +17,18 @@ const GOOGLE_DISCOVERY = {
revocationEndpoint: 'https://oauth2.googleapis.com/revoke',
};
// The old Photos Library API (photoslibrary.googleapis.com) was shut down
// in 2025. Google's replacement is the Photos Picker API which uses its
// own scope.
const SCOPES = [
'openid',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/photoslibrary',
'https://www.googleapis.com/auth/photospicker.mediaitems.readonly',
];
// Redirect URI uses the Android package name as scheme, matching what
// expo-auth-session/providers/google generates for installed Android apps.
// Result: com.bobthebob.massphotoapp:/oauthredirect
const PICKER_API = 'https://photospicker.googleapis.com/v1';
const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`;
class GoogleAUTH extends AuthServiceBase {
@@ -39,6 +40,8 @@ class GoogleAUTH extends AuthServiceBase {
this.email = null;
}
// ───────────────────── Authentication ─────────────────────
async authenticate() {
if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') {
console.warn('Google Auth: no Android client ID — using placeholder mode');
@@ -77,8 +80,6 @@ class GoogleAUTH extends AuthServiceBase {
throw err;
}
// Exchange authorization code for tokens.
// Android clients are public — PKCE code_verifier secures the exchange.
const tokenBody = new URLSearchParams({
client_id: ANDROID_CLIENT_ID,
code: result.params.code,
@@ -183,150 +184,198 @@ class GoogleAUTH extends AuthServiceBase {
return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' };
}
async getPhotos(pageSize = 50, pageToken = null) {
try {
const token = await this.getAccessToken();
// ───────────────── Photos Picker API ──────────────────
//
// The Picker API is interactive — the user must open the pickerUri in a
// browser to select which photos to share with the app. Once the user
// finishes selecting, mediaItemsSet becomes true and we can fetch items.
//
// Flow:
// 1. POST /v1/sessions → get { id, pickerUri }
// 2. Open pickerUri in browser → user selects photos → browser closes
// 3. Poll GET /v1/sessions/{id} until mediaItemsSet = true
// 4. GET /v1/mediaItems?sessionId={id}&pageSize=N → selected items
// Use the mediaItems:search endpoint (POST) instead of the deprecated
// GET /v1/mediaItems which Google shut down in 2025.
const body = { pageSize };
if (pageToken) body.pageToken = pageToken;
/**
* Create a new Picker session.
* Returns the full session object { id, pickerUri, mediaItemsSet, ... }
* or { error: { code, message } } on failure.
*/
async _createPickerSession() {
const token = await this.getAccessToken();
const res = await fetch(`${PICKER_API}/sessions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
const url = 'https://photoslibrary.googleapis.com/v1/mediaItems:search';
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
if (!res.ok) {
const body = await res.text();
logError('Picker Session Create', `${res.status}: ${body}`);
return { error: { code: res.status, message: body } };
}
return await res.json();
}
/**
* Poll a session until mediaItemsSet becomes true.
* Waits up to ~30 seconds (user needs time to select photos).
*/
async _waitForSession(sessionId) {
const token = await this.getAccessToken();
const maxAttempts = 20;
const delayMs = 1500;
for (let i = 0; i < maxAttempts; i++) {
const res = await fetch(`${PICKER_API}/sessions/${sessionId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
let errorBody = '';
let errorJson = null;
try {
errorBody = await response.text();
errorJson = JSON.parse(errorBody);
} catch (_) { /* not JSON */ }
const googleMessage =
errorJson?.error?.message || errorBody || `HTTP ${response.status}`;
const googleStatus = errorJson?.error?.status || '';
logError('Google Photos API', `${response.status} (${googleStatus}): ${googleMessage}`);
if (response.status === 401 && this.refreshToken && !pageToken) {
console.log('Token expired, refreshing and retrying…');
await this.refreshAccessToken();
return this.getPhotos(pageSize, pageToken);
}
if (response.status === 403) {
const isScopeProblem = googleMessage.toLowerCase().includes('scope');
return {
mediaItems: [],
nextPageToken: null,
error: {
code: 403,
status: googleStatus,
message: googleMessage,
needsReauth: isScopeProblem,
hint: isScopeProblem
? 'Token is missing the required scope. Re-authenticate in Settings.'
: googleMessage,
},
};
}
return { mediaItems: [], nextPageToken: null };
if (!res.ok) {
logError('Picker Session Poll', `${res.status}: ${await res.text()}`);
return null;
}
const data = await response.json();
return {
mediaItems: (data.mediaItems || []).map((item) => ({
source_service: 'GOOGLE_PHOTOS',
source_id: item.id,
title: item.filename || 'Untitled',
description: item.description || '',
local_path: item.baseUrl ? `${item.baseUrl}=w400-h400` : null,
remote_path: item.productUrl || '',
media_type: item.mimeType?.startsWith('video') ? 'video' : 'photo',
created_date: item.mediaMetadata?.creationTime || null,
})),
nextPageToken: data.nextPageToken || null,
};
} catch (error) {
logError('Get Photos', error);
const session = await res.json();
if (session.mediaItemsSet) return session;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
console.warn('Picker session timed out waiting for user selection');
return null;
}
/**
* Fetch media items from a ready Picker session.
*/
async _fetchPickerMediaItems(sessionId, pageSize = 100, pageToken = null) {
const token = await this.getAccessToken();
const params = new URLSearchParams({
sessionId,
pageSize: String(pageSize),
});
if (pageToken) params.append('pageToken', pageToken);
const res = await fetch(`${PICKER_API}/mediaItems?${params.toString()}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const body = await res.text();
logError('Picker MediaItems', `${res.status}: ${body}`);
return { mediaItems: [], nextPageToken: null };
}
return await res.json();
}
async getAlbums(pageSize = 50, pageToken = null) {
/**
* Normalise Picker API media items to the app's standard format.
*/
_normalisePickerItems(items) {
return (items || []).map((item) => {
const file = item.mediaFile || {};
const isVideo = file.mimeType?.startsWith('video');
return {
source_service: 'GOOGLE_PHOTOS',
source_id: item.id || file.id,
title: file.filename || 'Untitled',
description: '',
local_path: file.baseUrl ? `${file.baseUrl}=w400-h400` : null,
remote_path: file.baseUrl || '',
media_type: isVideo ? 'video' : 'photo',
created_date: file.mediaFileMetadata?.creationTime || null,
};
});
}
/**
* Full interactive flow: create session → open picker in browser →
* wait for user to select photos → fetch the selected items.
*
* @returns {{ mediaItems, nextPageToken, sessionId, error? }}
*/
async pickPhotos() {
try {
const token = await this.getAccessToken();
const params = new URLSearchParams({ pageSize: String(pageSize) });
if (pageToken) params.append('pageToken', pageToken);
// 1. Create session
console.log('Creating Google Photos Picker session…');
const session = await this._createPickerSession();
const response = await fetch(
`https://photoslibrary.googleapis.com/v1/albums?${params.toString()}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!response.ok) {
const errorBody = await response.text();
logError('Google Albums API', `${response.status}: ${errorBody}`);
return { albums: [], nextPageToken: null };
if (session?.error) {
return {
mediaItems: [], nextPageToken: null, sessionId: null,
error: { code: session.error.code, message: session.error.message },
};
}
const data = await response.json();
return { albums: data.albums || [], nextPageToken: data.nextPageToken || null };
if (!session?.id || !session?.pickerUri) {
return {
mediaItems: [], nextPageToken: null, sessionId: null,
error: { code: 0, message: 'Failed to create Picker session' },
};
}
console.log('Picker session created:', session.id);
console.log('Opening picker for user selection…');
// 2. Open the picker URI in a browser for the user to select photos
await WebBrowser.openBrowserAsync(session.pickerUri, {
dismissButtonStyle: 'done',
presentationStyle: 'pageSheet',
});
console.log('Browser closed — polling for selected photos…');
// 3. Poll until the user's selections are ready
const ready = await this._waitForSession(session.id);
if (!ready) {
return {
mediaItems: [], nextPageToken: null, sessionId: session.id,
error: { code: 0, message: 'No photos were selected or the picker was dismissed' },
};
}
// 4. Fetch selected items
const result = await this._fetchPickerMediaItems(session.id, 100, null);
const mediaItems = this._normalisePickerItems(result.mediaItems);
console.log(`Picker returned ${mediaItems.length} selected items`);
return {
mediaItems,
nextPageToken: result.nextPageToken || null,
sessionId: session.id,
};
} catch (error) {
logError('Get Albums', error);
return { albums: [], nextPageToken: null };
logError('Pick Photos', error);
return { mediaItems: [], nextPageToken: null, sessionId: null };
}
}
// NOTE: Upload requires the `photoslibrary.appendonly` scope.
async uploadPhoto(filePath, filename) {
/**
* Fetch more items from an existing Picker session (pagination).
*/
async getMorePhotos(pageSize = 100, pageToken, sessionId) {
try {
const token = await this.getAccessToken();
const uploadResponse = await fetch('https://photoslibrary.googleapis.com/v1/uploads', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/octet-stream',
'X-Goog-Upload-File-Name': filename,
'X-Goog-Upload-Protocol': 'raw',
},
body: await fetch(filePath).then((r) => r.blob()),
});
if (!uploadResponse.ok) {
logError('Upload Photo', 'Upload bytes failed: HTTP ' + uploadResponse.status);
return null;
if (!sessionId || !pageToken) {
return { mediaItems: [], nextPageToken: null, sessionId };
}
const uploadToken = await uploadResponse.text();
const createResponse = await fetch(
'https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate',
{
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
newMediaItems: [{ simpleMediaItem: { uploadToken, fileName: filename } }],
}),
}
);
const result = await this._fetchPickerMediaItems(sessionId, pageSize, pageToken);
const mediaItems = this._normalisePickerItems(result.mediaItems);
if (!createResponse.ok) {
logError('Upload Photo', 'Create media item failed: HTTP ' + createResponse.status);
return null;
}
return await createResponse.json();
return {
mediaItems,
nextPageToken: result.nextPageToken || null,
sessionId,
};
} catch (error) {
logError('Upload Photo', error);
return null;
logError('Get More Photos', error);
return { mediaItems: [], nextPageToken: null, sessionId };
}
}
}