feat: integrate device media service for local photo access and update service icons/names

This commit is contained in:
bobbert committed 2026-02-24 23:18:54 +00:00
1 parent e5dbdfeba3
commit 0ef59ba5d3
9 files changed
+313 -94

No files matched your search

+93 -11
View File
@@ -19,12 +19,12 @@ const GOOGLE_DISCOVERY = {
const SCOPES = [
'openid',
'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',
'https://www.googleapis.com/auth/photoslibrary',
];
// 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
@@ -57,17 +57,26 @@ class GoogleAUTH extends AuthServiceBase {
}
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' },
extraParams: {
access_type: 'offline',
prompt: 'consent',
},
});
const result = await authRequest.promptAsync(GOOGLE_DISCOVERY);
@@ -104,6 +113,21 @@ class GoogleAUTH extends AuthServiceBase {
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';
@@ -189,16 +213,67 @@ class GoogleAUTH extends AuthServiceBase {
async getPhotos(pageSize = 50, pageToken = null) {
try {
const token = await this.getAccessToken();
const params = new URLSearchParams({ pageSize: String(pageSize) });
if (pageToken) params.append('pageToken', pageToken);
const response = await fetch(
`https://photoslibrary.googleapis.com/v1/mediaItems?${params.toString()}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
// 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;
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 (!response.ok) {
console.error('Google Photos API error:', response.status);
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 };
}
@@ -233,7 +308,11 @@ class GoogleAUTH extends AuthServiceBase {
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!response.ok) return { albums: [], nextPageToken: null };
if (!response.ok) {
const errorBody = await response.text();
console.error(`Google Albums API error ${response.status}:`, errorBody);
return { albums: [], nextPageToken: null };
}
const data = await response.json();
return { albums: data.albums || [], nextPageToken: data.nextPageToken || null };
} catch (error) {
@@ -242,6 +321,9 @@ class GoogleAUTH extends AuthServiceBase {
}
}
// 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') {
try {
const token = await this.getAccessToken();