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

+7 -3
View File
@@ -49,10 +49,14 @@ async function main() {
console.log(` Failed: ${err.message}\n`);
}
// Test 2 — List media items
console.log('[2] Listing media items (first 10)...');
// Test 2 — Search media items (replaces deprecated GET /mediaItems)
console.log('[2] Searching media items (first 10)...');
try {
const res = await fetch(`${API_BASE}/mediaItems?pageSize=10`, { headers });
const res = await fetch(`${API_BASE}/mediaItems:search`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ pageSize: 10 }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const data = await res.json();
const items = data.mediaItems || [];
+7 -4
View File
@@ -7,7 +7,8 @@
## Step 2: Enable the Photos Library API
1. Go to **APIs & Services** → **Library**
2. Search for **Photos Library API** → Enable it
2. Search for **Photos Library API** → **Enable** it
3. ⚠️ This step is **required** — without it, all Photos API calls return 403
## Step 3: Configure the OAuth Consent Screen
1. Go to **APIs & Services** → **OAuth consent screen**
@@ -16,13 +17,15 @@
- **App name**: MASS Photo App
- **User support email**: your email
- **Developer contact**: your email
4. **Scopes** → Add or Remove Scopes:
4. **Scopes** → Add or Remove Scopes → add these:
- `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`
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,
Google will silently strip the Photos scope from the granted token,
causing a 403 "insufficient authentication scopes" error.
## Step 4: Create an Android OAuth Client ID
1. Go to **APIs & Services** → **Credentials**
+3 -3
View File
@@ -29,9 +29,9 @@ const GooglePhotosAPI = (getAccessToken) => {
return {
listMediaItems: (pageSize = 25, pageToken) => {
const params = new URLSearchParams({ pageSize });
if (pageToken) params.append('pageToken', pageToken);
return callFetch(`${API_ROOT}/mediaItems?${params.toString()}`, 'GET');
const body = { pageSize };
if (pageToken) body.pageToken = pageToken;
return callFetch(`${API_ROOT}/mediaItems:search`, 'POST', body);
},
getMediaItem: (mediaItemId) => callFetch(`${API_ROOT}/mediaItems/${mediaItemId}`, 'GET'),
searchMediaItems: (filters) => callFetch(`${API_ROOT}/mediaItems:search`, 'POST', filters),
+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();
+2 -2
View File
@@ -10,9 +10,9 @@ const THUMB_SIZE = 60;
const THUMB_GAP = 4;
const getServiceIcon = (type) =>
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) =>
({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
/**
* Fullscreen media viewer with thumbnail carousel.
+2 -2
View File
@@ -30,8 +30,8 @@ const CollectionsScreen = ({ route, navigation }) => {
}, [userId])
);
const getServiceIcon = (type) => ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) => ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
const loadCollections = async () => {
try {
+54 -66
View File
@@ -5,7 +5,7 @@ import {
import { useFocusEffect } from '@react-navigation/native';
import { useTheme } from '../services/ThemeContext';
import DatabaseService from '../services/DatabaseService';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import DeviceMediaService from '../services/DeviceMediaService';
import NASAUTH from '../AUTH/NASAUTH';
import TopBar from '../components/TopBar';
import PhotoGrid from '../components/PhotoGrid';
@@ -24,7 +24,8 @@ const HomeScreen = ({ navigation, route }) => {
const [refreshing, setRefreshing] = useState(false);
const [selectedMedia, setSelectedMedia] = useState(null);
const [viewerVisible, setViewerVisible] = useState(false);
const [googleNextPage, setGoogleNextPage] = useState(null);
const [deviceEndCursor, setDeviceEndCursor] = useState(null);
const [deviceHasMore, setDeviceHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
useFocusEffect(
@@ -53,45 +54,21 @@ const HomeScreen = ({ navigation, route }) => {
const loadAllMedia = async () => {
if (!loading) setLoading(true);
try {
const mediaFromDb = await DatabaseService.getMediaByUser(userId);
const newMedia = [];
// Google Photos — restore tokens from the linked account and fetch
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;
// Set expiresAt to 0 so getAccessToken() will refresh if needed
googleAuth.expiresAt = data.expiresAt || 0;
const photos = await googleAuth.getPhotos(100);
if (photos?.mediaItems?.length) newMedia.push(...photos.mediaItems);
setGoogleNextPage(photos?.nextPageToken || null);
// Persist refreshed tokens back to DB if they changed
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 fetch error:', err);
// ─── Device photos (camera roll + Google Photos synced images) ───
try {
const deviceResult = await DeviceMediaService.getPhotos(100);
if (deviceResult.mediaItems.length) {
newMedia.push(...deviceResult.mediaItems);
}
setDeviceEndCursor(deviceResult.endCursor);
setDeviceHasMore(deviceResult.hasNextPage);
} catch (err) {
console.error('Device media fetch error:', err);
}
// iCloud (placeholder)
const iosAccount = linkedAccounts.find((a) => a.service_type === 'ICLOUD_PHOTOS');
if (iosAccount) {
console.log('iCloud: placeholder — would fetch photos here');
}
// NAS fetch
// ─── NAS fetch ───
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
if (nasAccount) {
try {
@@ -106,8 +83,15 @@ const HomeScreen = ({ navigation, route }) => {
}
}
const combined = [...mediaFromDb, ...newMedia];
const unique = Array.from(new Map(combined.map((m) => [m.source_id || m.id || m.path, m])).values());
// ─── iCloud (placeholder) ───
const iosAccount = linkedAccounts.find((a) => a.service_type === 'ICLOUD_PHOTOS');
if (iosAccount) {
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);
} catch (err) {
console.error('Error loading media:', err);
@@ -127,26 +111,20 @@ const HomeScreen = ({ navigation, route }) => {
};
const loadMorePhotos = async () => {
if (loadingMore || !googleNextPage) return;
if (loadingMore || !deviceHasMore) return;
setLoadingMore(true);
try {
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);
if (photos?.mediaItems?.length) {
setAllMedia((prev) => {
const combined = [...prev, ...photos.mediaItems];
return Array.from(new Map(combined.map((m) => [m.source_id || m.id || m.path, m])).values());
});
}
setGoogleNextPage(photos?.nextPageToken || null);
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()
);
});
}
setDeviceEndCursor(result.endCursor);
setDeviceHasMore(result.hasNextPage);
} catch (err) {
console.error('Error loading more photos:', err);
} finally {
@@ -154,8 +132,8 @@ const HomeScreen = ({ navigation, route }) => {
}
};
const getServiceIcon = (type) => ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) => ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
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);
if (loading && allMedia.length === 0) {
return (
@@ -166,6 +144,16 @@ const HomeScreen = ({ navigation, route }) => {
);
}
// Build the list of available source filters
const sourceFilters = [
{ key: 'DEVICE', icon: '📱', name: 'On Device' },
...linkedAccounts.map((acc) => ({
key: acc.service_type,
icon: getServiceIcon(acc.service_type),
name: getServiceName(acc.service_type),
})),
];
const FilterChips = () => (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterRow} style={styles.filterContainer}>
<TouchableOpacity
@@ -174,15 +162,15 @@ const HomeScreen = ({ navigation, route }) => {
>
<Text style={[styles.chipText, selectedFilter === 'all' && { color: theme.primary, fontWeight: '600' }]}>All</Text>
</TouchableOpacity>
{linkedAccounts.map((acc) => (
{sourceFilters.map((f) => (
<TouchableOpacity
key={acc.id}
style={[styles.chip, { backgroundColor: selectedFilter === acc.service_type ? theme.chipActiveBg : theme.chipBg }]}
onPress={() => setSelectedFilter(acc.service_type)}
key={f.key}
style={[styles.chip, { backgroundColor: selectedFilter === f.key ? theme.chipActiveBg : theme.chipBg }]}
onPress={() => setSelectedFilter(f.key)}
>
<Text style={styles.chipIcon}>{getServiceIcon(acc.service_type)}</Text>
<Text style={[styles.chipText, selectedFilter === acc.service_type && { color: theme.primary, fontWeight: '600' }]}>
{getServiceName(acc.service_type)}
<Text style={styles.chipIcon}>{f.icon}</Text>
<Text style={[styles.chipText, selectedFilter === f.key && { color: theme.primary, fontWeight: '600' }]}>
{f.name}
</Text>
</TouchableOpacity>
))}
@@ -198,14 +186,14 @@ const HomeScreen = ({ navigation, route }) => {
onMenuPress={() => navigation.navigate('Settings', { userId })}
/>
{linkedAccounts.length > 0 && <FilterChips />}
<FilterChips />
{filteredMedia.length === 0 ? (
<View style={styles.centerContainer}>
<Text style={{ fontSize: 56, marginBottom: 16 }}>📸</Text>
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Photos Yet</Text>
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
Photos from your connected services will appear here
Grant media library permission or take some photos to get started
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: theme.primary }]}
+5 -3
View File
@@ -76,12 +76,14 @@ const SettingsScreen = ({ navigation, route }) => {
const checkSingleHealth = async (acc) => {
try {
if (acc.service_type === 'GOOGLE_PHOTOS') {
// The Photos Library REST API is deprecated; health-check the token
// by calling the userinfo endpoint instead.
const gAuth = new GoogleAUTH();
gAuth.accessToken = acc.account_data?.accessToken;
gAuth.refreshToken = acc.account_data?.refreshToken;
gAuth.expiresAt = Date.now() + 3600000;
const res = await gAuth.getPhotos(1);
return res?.mediaItems !== undefined ? 'healthy' : 'error';
gAuth.expiresAt = acc.account_data?.expiresAt || 0;
const info = await gAuth.getUserInfo();
return info?.email ? 'healthy' : 'error';
}
if (acc.service_type === 'NAS') {
const nasAuth = new NASAUTH();
+140
View File
@@ -0,0 +1,140 @@
import * as MediaLibrary from 'expo-media-library';
/**
* Fetches photos and videos directly from the Android device media store.
*
* This replaces the deprecated Google Photos Library REST API.
* On Android, the media store includes all photos visible to the device —
* camera roll, screenshots, downloads, and images synced by the Google
* Photos app. No OAuth token is needed; only the MEDIA_LIBRARY permission.
*/
const DeviceMediaService = {
/**
* Request media-library permission (camera roll / storage).
* Returns true if granted.
*/
requestPermission: async () => {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
console.warn('Media library permission denied');
return false;
}
return true;
},
/**
* Fetch a page of media assets from the device.
*
* @param {number} pageSize Number of items per page (default 100).
* @param {string} after Cursor returned by a previous call (for pagination).
* @param {string} mediaType 'photo' | 'video' | 'all' (default 'all').
* @returns {{ mediaItems: Array, endCursor: string|null, hasNextPage: boolean }}
*/
getPhotos: async (pageSize = 100, after = null, mediaType = 'all') => {
const hasPermission = await DeviceMediaService.requestPermission();
if (!hasPermission) {
return { mediaItems: [], endCursor: null, hasNextPage: false };
}
const mediaTypeFilter =
mediaType === 'photo' ? MediaLibrary.MediaType.photo
: mediaType === 'video' ? MediaLibrary.MediaType.video
: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video];
const options = {
first: pageSize,
mediaType: mediaTypeFilter,
sortBy: [[MediaLibrary.SortBy.creationTime, false]], // newest first
};
if (after) options.after = after;
const result = await MediaLibrary.getAssetsAsync(options);
const mediaItems = result.assets.map((asset) => ({
source_service: 'DEVICE',
source_id: asset.id,
title: asset.filename || 'Untitled',
description: '',
local_path: asset.uri,
remote_path: '',
media_type: asset.mediaType === 'video' ? 'video' : 'photo',
created_date: asset.creationTime
? new Date(asset.creationTime).toISOString()
: null,
width: asset.width,
height: asset.height,
duration: asset.duration || 0,
}));
return {
mediaItems,
endCursor: result.endCursor || null,
hasNextPage: result.hasNextPage || false,
};
},
/**
* Fetch all albums from the device (including smart albums).
*/
getAlbums: async () => {
const hasPermission = await DeviceMediaService.requestPermission();
if (!hasPermission) return [];
const albums = await MediaLibrary.getAlbumsAsync({
includeSmartAlbums: true,
});
return albums.map((album) => ({
id: album.id,
title: album.title,
count: album.assetCount,
}));
},
/**
* Fetch assets inside a specific album.
*/
getAlbumPhotos: async (albumId, pageSize = 100, after = null) => {
const hasPermission = await DeviceMediaService.requestPermission();
if (!hasPermission) {
return { mediaItems: [], endCursor: null, hasNextPage: false };
}
const album = await MediaLibrary.getAlbumAsync(albumId);
if (!album) return { mediaItems: [], endCursor: null, hasNextPage: false };
const options = {
first: pageSize,
album,
sortBy: [[MediaLibrary.SortBy.creationTime, false]],
};
if (after) options.after = after;
const result = await MediaLibrary.getAssetsAsync(options);
const mediaItems = result.assets.map((asset) => ({
source_service: 'DEVICE',
source_id: asset.id,
title: asset.filename || 'Untitled',
description: '',
local_path: asset.uri,
remote_path: '',
media_type: asset.mediaType === 'video' ? 'video' : 'photo',
created_date: asset.creationTime
? new Date(asset.creationTime).toISOString()
: null,
width: asset.width,
height: asset.height,
duration: asset.duration || 0,
}));
return {
mediaItems,
endCursor: result.endCursor || null,
hasNextPage: result.hasNextPage || false,
};
},
};
export default DeviceMediaService;