mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
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:
1 parent
f6c61ab829
commit
5c096fbab4
5 files changed
+363
-201
No files matched your search
+16
-15
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+181
-132
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
-7
@@ -6,6 +6,7 @@ import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
import DatabaseService from '../services/DatabaseService';
|
||||
import DeviceMediaService from '../services/DeviceMediaService';
|
||||
import GoogleAUTH from '../AUTH/GoogleAUTH';
|
||||
import NASAUTH from '../AUTH/NASAUTH';
|
||||
import TopBar from '../components/TopBar';
|
||||
import PhotoGrid from '../components/PhotoGrid';
|
||||
@@ -26,6 +27,8 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
const [viewerVisible, setViewerVisible] = useState(false);
|
||||
const [deviceEndCursor, setDeviceEndCursor] = useState(null);
|
||||
const [deviceHasMore, setDeviceHasMore] = useState(false);
|
||||
const [googleNextPage, setGoogleNextPage] = useState(null);
|
||||
const [googleSessionId, setGoogleSessionId] = useState(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
useFocusEffect(
|
||||
@@ -56,7 +59,7 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
try {
|
||||
const newMedia = [];
|
||||
|
||||
// ─── Device photos (camera roll + Google Photos synced images) ───
|
||||
// ─── Device photos (local camera roll, screenshots, etc.) ───
|
||||
try {
|
||||
const deviceResult = await DeviceMediaService.getPhotos(100);
|
||||
if (deviceResult.mediaItems.length) {
|
||||
@@ -68,6 +71,59 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
console.error('Device media fetch error:', err);
|
||||
}
|
||||
|
||||
// ─── Google Photos cloud (via Picker API — includes cloud-only photos) ───
|
||||
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||
if (googleAccount) {
|
||||
try {
|
||||
const data = googleAccount.account_data || {};
|
||||
const googleAuth = new GoogleAUTH();
|
||||
googleAuth.accessToken = data.accessToken || null;
|
||||
googleAuth.refreshToken = data.refreshToken || null;
|
||||
googleAuth.expiresAt = data.expiresAt || 0;
|
||||
|
||||
let photos = await googleAuth.getPhotos(100, null, null);
|
||||
|
||||
// If the stored token has stale scopes (missing photospicker scope),
|
||||
// automatically re-authenticate to get a fresh token and retry.
|
||||
if (photos?.error?.code === 403) {
|
||||
console.log('Stored token missing Picker scope — re-authenticating…');
|
||||
try {
|
||||
await googleAuth.revokeAccess();
|
||||
const fresh = await googleAuth.authenticate();
|
||||
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
|
||||
accessToken: fresh.accessToken,
|
||||
refreshToken: fresh.refreshToken || null,
|
||||
expiresAt: googleAuth.expiresAt,
|
||||
email: fresh.email || data.email || 'google-user',
|
||||
});
|
||||
photos = await googleAuth.getPhotos(100, null, null);
|
||||
} catch (authErr) {
|
||||
console.warn('Google re-auth failed:', authErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (photos?.error) {
|
||||
console.warn('Google Photos cloud fetch failed:', photos.error.message);
|
||||
} else {
|
||||
if (photos?.mediaItems?.length) newMedia.push(...photos.mediaItems);
|
||||
setGoogleNextPage(photos?.nextPageToken || null);
|
||||
setGoogleSessionId(photos?.sessionId || null);
|
||||
}
|
||||
|
||||
// Persist any refreshed tokens back to DB
|
||||
if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) {
|
||||
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
|
||||
accessToken: googleAuth.accessToken,
|
||||
refreshToken: googleAuth.refreshToken || data.refreshToken,
|
||||
expiresAt: googleAuth.expiresAt,
|
||||
email: data.email || 'google-user',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Google Photos cloud fetch error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── NAS fetch ───
|
||||
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
|
||||
if (nasAccount) {
|
||||
@@ -89,6 +145,7 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
console.log('iCloud: placeholder — would fetch photos here');
|
||||
}
|
||||
|
||||
// Deduplicate by source_id (Google Photos cloud items may overlap with device items by filename)
|
||||
const unique = Array.from(
|
||||
new Map(newMedia.map((m) => [m.source_id || m.id || m.local_path, m])).values()
|
||||
);
|
||||
@@ -111,20 +168,59 @@ const HomeScreen = ({ navigation, route }) => {
|
||||
};
|
||||
|
||||
const loadMorePhotos = async () => {
|
||||
if (loadingMore || !deviceHasMore) return;
|
||||
if (loadingMore) return;
|
||||
// Load more from whichever source has more pages
|
||||
const canLoadDevice = deviceHasMore;
|
||||
const canLoadGoogle = !!googleNextPage;
|
||||
if (!canLoadDevice && !canLoadGoogle) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await DeviceMediaService.getPhotos(100, deviceEndCursor);
|
||||
if (result.mediaItems.length) {
|
||||
const moreMedia = [];
|
||||
|
||||
// More device photos
|
||||
if (canLoadDevice) {
|
||||
const result = await DeviceMediaService.getPhotos(100, deviceEndCursor);
|
||||
if (result.mediaItems.length) moreMedia.push(...result.mediaItems);
|
||||
setDeviceEndCursor(result.endCursor);
|
||||
setDeviceHasMore(result.hasNextPage);
|
||||
}
|
||||
|
||||
// More Google Photos cloud photos (reuse the Picker session)
|
||||
if (canLoadGoogle) {
|
||||
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||
if (googleAccount) {
|
||||
const data = googleAccount.account_data || {};
|
||||
const googleAuth = new GoogleAUTH();
|
||||
googleAuth.accessToken = data.accessToken || null;
|
||||
googleAuth.refreshToken = data.refreshToken || null;
|
||||
googleAuth.expiresAt = data.expiresAt || 0;
|
||||
|
||||
const photos = await googleAuth.getPhotos(100, googleNextPage, googleSessionId);
|
||||
if (photos?.mediaItems?.length) moreMedia.push(...photos.mediaItems);
|
||||
setGoogleNextPage(photos?.nextPageToken || null);
|
||||
if (photos?.sessionId) setGoogleSessionId(photos.sessionId);
|
||||
|
||||
// Persist refreshed tokens
|
||||
if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) {
|
||||
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
|
||||
accessToken: googleAuth.accessToken,
|
||||
refreshToken: googleAuth.refreshToken || data.refreshToken,
|
||||
expiresAt: googleAuth.expiresAt,
|
||||
email: data.email || 'google-user',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (moreMedia.length) {
|
||||
setAllMedia((prev) => {
|
||||
const combined = [...prev, ...result.mediaItems];
|
||||
const combined = [...prev, ...moreMedia];
|
||||
return Array.from(
|
||||
new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values()
|
||||
);
|
||||
});
|
||||
}
|
||||
setDeviceEndCursor(result.endCursor);
|
||||
setDeviceHasMore(result.hasNextPage);
|
||||
} catch (err) {
|
||||
console.error('Error loading more photos:', err);
|
||||
} finally {
|
||||
|
||||
Reference in new issue
Block a user