From 0ef59ba5d3734c5a89d51f8c912618263d363fc4 Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Tue, 24 Feb 2026 23:18:54 +0000 Subject: [PATCH] feat: integrate device media service for local photo access and update service icons/names --- TEST/testGooglePhotosAPI.js | 10 ++- ignorefiles/GOOGLE_OAUTH_SETUP.md | 11 ++- src/API/AndroidAPI.js | 6 +- src/AUTH/GoogleAUTH.js | 104 ++++++++++++++++++--- src/components/MediaViewer.js | 4 +- src/screens/AlbumsScreen.js | 4 +- src/screens/HomeScreen.js | 120 +++++++++++-------------- src/screens/SettingsScreen.js | 8 +- src/services/DeviceMediaService.js | 140 +++++++++++++++++++++++++++++ 9 files changed, 313 insertions(+), 94 deletions(-) create mode 100644 src/services/DeviceMediaService.js diff --git a/TEST/testGooglePhotosAPI.js b/TEST/testGooglePhotosAPI.js index 2bc4316..3a4074a 100644 --- a/TEST/testGooglePhotosAPI.js +++ b/TEST/testGooglePhotosAPI.js @@ -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 || []; diff --git a/ignorefiles/GOOGLE_OAUTH_SETUP.md b/ignorefiles/GOOGLE_OAUTH_SETUP.md index f0e9a3d..ed54de9 100644 --- a/ignorefiles/GOOGLE_OAUTH_SETUP.md +++ b/ignorefiles/GOOGLE_OAUTH_SETUP.md @@ -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** diff --git a/src/API/AndroidAPI.js b/src/API/AndroidAPI.js index 8519b94..71c6a63 100644 --- a/src/API/AndroidAPI.js +++ b/src/API/AndroidAPI.js @@ -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), diff --git a/src/AUTH/GoogleAUTH.js b/src/AUTH/GoogleAUTH.js index 3e30b8e..1cb760d 100644 --- a/src/AUTH/GoogleAUTH.js +++ b/src/AUTH/GoogleAUTH.js @@ -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(); diff --git a/src/components/MediaViewer.js b/src/components/MediaViewer.js index cdba327..c4ba5c8 100644 --- a/src/components/MediaViewer.js +++ b/src/components/MediaViewer.js @@ -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. diff --git a/src/screens/AlbumsScreen.js b/src/screens/AlbumsScreen.js index e1f0f23..d0221d9 100644 --- a/src/screens/AlbumsScreen.js +++ b/src/screens/AlbumsScreen.js @@ -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 { diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index bb376d8..c97fbe8 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -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 = () => ( { > All - {linkedAccounts.map((acc) => ( + {sourceFilters.map((f) => ( setSelectedFilter(acc.service_type)} + key={f.key} + style={[styles.chip, { backgroundColor: selectedFilter === f.key ? theme.chipActiveBg : theme.chipBg }]} + onPress={() => setSelectedFilter(f.key)} > - {getServiceIcon(acc.service_type)} - - {getServiceName(acc.service_type)} + {f.icon} + + {f.name} ))} @@ -198,14 +186,14 @@ const HomeScreen = ({ navigation, route }) => { onMenuPress={() => navigation.navigate('Settings', { userId })} /> - {linkedAccounts.length > 0 && } + {filteredMedia.length === 0 ? ( 📸 No Photos Yet - Photos from your connected services will appear here + Grant media library permission or take some photos to get started { 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(); diff --git a/src/services/DeviceMediaService.js b/src/services/DeviceMediaService.js new file mode 100644 index 0000000..d471477 --- /dev/null +++ b/src/services/DeviceMediaService.js @@ -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; +