mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
feat: integrate device media service for local photo access and update service icons/names
This commit is contained in:
1 parent
e5dbdfeba3
commit
0ef59ba5d3
9 files changed
+309
-90
No files matched your search
@@ -49,10 +49,14 @@ async function main() {
|
|||||||
console.log(` Failed: ${err.message}\n`);
|
console.log(` Failed: ${err.message}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test 2 — List media items
|
// Test 2 — Search media items (replaces deprecated GET /mediaItems)
|
||||||
console.log('[2] Listing media items (first 10)...');
|
console.log('[2] Searching media items (first 10)...');
|
||||||
try {
|
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()}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const items = data.mediaItems || [];
|
const items = data.mediaItems || [];
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
|
|
||||||
## Step 2: Enable the Photos Library API
|
## Step 2: Enable the Photos Library API
|
||||||
1. Go to **APIs & Services** → **Library**
|
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
|
## Step 3: Configure the OAuth Consent Screen
|
||||||
1. Go to **APIs & Services** → **OAuth consent screen**
|
1. Go to **APIs & Services** → **OAuth consent screen**
|
||||||
@@ -16,13 +17,15 @@
|
|||||||
- **App name**: MASS Photo App
|
- **App name**: MASS Photo App
|
||||||
- **User support email**: your email
|
- **User support email**: your email
|
||||||
- **Developer contact**: your email
|
- **Developer contact**: your email
|
||||||
4. **Scopes** → Add or Remove Scopes:
|
4. **Scopes** → Add or Remove Scopes → add these:
|
||||||
- `openid`
|
- `openid`
|
||||||
- `https://www.googleapis.com/auth/photoslibrary`
|
- `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.email`
|
||||||
- `https://www.googleapis.com/auth/userinfo.profile`
|
- `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
|
## Step 4: Create an Android OAuth Client ID
|
||||||
1. Go to **APIs & Services** → **Credentials**
|
1. Go to **APIs & Services** → **Credentials**
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ const GooglePhotosAPI = (getAccessToken) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
listMediaItems: (pageSize = 25, pageToken) => {
|
listMediaItems: (pageSize = 25, pageToken) => {
|
||||||
const params = new URLSearchParams({ pageSize });
|
const body = { pageSize };
|
||||||
if (pageToken) params.append('pageToken', pageToken);
|
if (pageToken) body.pageToken = pageToken;
|
||||||
return callFetch(`${API_ROOT}/mediaItems?${params.toString()}`, 'GET');
|
return callFetch(`${API_ROOT}/mediaItems:search`, 'POST', body);
|
||||||
},
|
},
|
||||||
getMediaItem: (mediaItemId) => callFetch(`${API_ROOT}/mediaItems/${mediaItemId}`, 'GET'),
|
getMediaItem: (mediaItemId) => callFetch(`${API_ROOT}/mediaItems/${mediaItemId}`, 'GET'),
|
||||||
searchMediaItems: (filters) => callFetch(`${API_ROOT}/mediaItems:search`, 'POST', filters),
|
searchMediaItems: (filters) => callFetch(`${API_ROOT}/mediaItems:search`, 'POST', filters),
|
||||||
|
|||||||
+93
-11
@@ -19,12 +19,12 @@ const GOOGLE_DISCOVERY = {
|
|||||||
|
|
||||||
const SCOPES = [
|
const SCOPES = [
|
||||||
'openid',
|
'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.email',
|
||||||
'https://www.googleapis.com/auth/userinfo.profile',
|
'https://www.googleapis.com/auth/userinfo.profile',
|
||||||
|
'https://www.googleapis.com/auth/photoslibrary',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
// Redirect URI uses the Android package name as scheme, matching what
|
// Redirect URI uses the Android package name as scheme, matching what
|
||||||
// expo-auth-session/providers/google generates for installed Android apps.
|
// expo-auth-session/providers/google generates for installed Android apps.
|
||||||
// Result: com.bobthebob.massphotoapp:/oauthredirect
|
// Result: com.bobthebob.massphotoapp:/oauthredirect
|
||||||
@@ -57,17 +57,26 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('OAuth redirect URI:', REDIRECT_URI);
|
console.log('OAuth redirect URI:', REDIRECT_URI);
|
||||||
|
console.log('Requesting scopes:', SCOPES.join(' '));
|
||||||
|
|
||||||
// Auth request uses the Android client ID.
|
// Auth request uses the Android client ID.
|
||||||
// Android OAuth clients are verified by package name + SHA-1 signing cert,
|
// Android OAuth clients are verified by package name + SHA-1 signing cert,
|
||||||
// so Google accepts the package-name scheme redirect without URI registration.
|
// 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({
|
const authRequest = new AuthSession.AuthRequest({
|
||||||
clientId: ANDROID_CLIENT_ID,
|
clientId: ANDROID_CLIENT_ID,
|
||||||
scopes: SCOPES,
|
scopes: SCOPES,
|
||||||
redirectUri: REDIRECT_URI,
|
redirectUri: REDIRECT_URI,
|
||||||
responseType: AuthSession.ResponseType.Code,
|
responseType: AuthSession.ResponseType.Code,
|
||||||
usePKCE: true,
|
usePKCE: true,
|
||||||
extraParams: { access_type: 'offline', prompt: 'consent' },
|
extraParams: {
|
||||||
|
access_type: 'offline',
|
||||||
|
prompt: 'consent',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await authRequest.promptAsync(GOOGLE_DISCOVERY);
|
const result = await authRequest.promptAsync(GOOGLE_DISCOVERY);
|
||||||
@@ -104,6 +113,21 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
this.idToken = tokenData.id_token || null;
|
this.idToken = tokenData.id_token || null;
|
||||||
this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000;
|
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();
|
const userInfo = await this.fetchUserInfo();
|
||||||
this.email = userInfo?.email || 'google-user';
|
this.email = userInfo?.email || 'google-user';
|
||||||
|
|
||||||
@@ -189,16 +213,67 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
async getPhotos(pageSize = 50, pageToken = null) {
|
async getPhotos(pageSize = 50, pageToken = null) {
|
||||||
try {
|
try {
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
const params = new URLSearchParams({ pageSize: String(pageSize) });
|
|
||||||
if (pageToken) params.append('pageToken', pageToken);
|
|
||||||
|
|
||||||
const response = await fetch(
|
// Use the mediaItems:search endpoint (POST) instead of the deprecated
|
||||||
`https://photoslibrary.googleapis.com/v1/mediaItems?${params.toString()}`,
|
// GET /v1/mediaItems which Google shut down in 2025.
|
||||||
{ headers: { Authorization: `Bearer ${token}` } }
|
// 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) {
|
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 };
|
return { mediaItems: [], nextPageToken: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +308,11 @@ class GoogleAUTH extends AuthServiceBase {
|
|||||||
{ headers: { Authorization: `Bearer ${token}` } }
|
{ 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();
|
const data = await response.json();
|
||||||
return { albums: data.albums || [], nextPageToken: data.nextPageToken || null };
|
return { albums: data.albums || [], nextPageToken: data.nextPageToken || null };
|
||||||
} catch (error) {
|
} 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') {
|
async uploadPhoto(filePath, filename, mimeType = 'image/jpeg') {
|
||||||
try {
|
try {
|
||||||
const token = await this.getAccessToken();
|
const token = await this.getAccessToken();
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ const THUMB_SIZE = 60;
|
|||||||
const THUMB_GAP = 4;
|
const THUMB_GAP = 4;
|
||||||
|
|
||||||
const getServiceIcon = (type) =>
|
const getServiceIcon = (type) =>
|
||||||
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||||
const getServiceName = (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.
|
* Fullscreen media viewer with thumbnail carousel.
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ const CollectionsScreen = ({ route, navigation }) => {
|
|||||||
}, [userId])
|
}, [userId])
|
||||||
);
|
);
|
||||||
|
|
||||||
const getServiceIcon = (type) => ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||||
const getServiceName = (type) => ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
|
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud Photos', NAS: 'NAS Storage' }[type] || type);
|
||||||
|
|
||||||
const loadCollections = async () => {
|
const loadCollections = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+50
-62
@@ -5,7 +5,7 @@ import {
|
|||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { useTheme } from '../services/ThemeContext';
|
import { useTheme } from '../services/ThemeContext';
|
||||||
import DatabaseService from '../services/DatabaseService';
|
import DatabaseService from '../services/DatabaseService';
|
||||||
import GoogleAUTH from '../AUTH/GoogleAUTH';
|
import DeviceMediaService from '../services/DeviceMediaService';
|
||||||
import NASAUTH from '../AUTH/NASAUTH';
|
import NASAUTH from '../AUTH/NASAUTH';
|
||||||
import TopBar from '../components/TopBar';
|
import TopBar from '../components/TopBar';
|
||||||
import PhotoGrid from '../components/PhotoGrid';
|
import PhotoGrid from '../components/PhotoGrid';
|
||||||
@@ -24,7 +24,8 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [selectedMedia, setSelectedMedia] = useState(null);
|
const [selectedMedia, setSelectedMedia] = useState(null);
|
||||||
const [viewerVisible, setViewerVisible] = useState(false);
|
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);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -53,45 +54,21 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
const loadAllMedia = async () => {
|
const loadAllMedia = async () => {
|
||||||
if (!loading) setLoading(true);
|
if (!loading) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const mediaFromDb = await DatabaseService.getMediaByUser(userId);
|
|
||||||
const newMedia = [];
|
const newMedia = [];
|
||||||
|
|
||||||
// Google Photos — restore tokens from the linked account and fetch
|
// ─── Device photos (camera roll + Google Photos synced images) ───
|
||||||
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
|
||||||
if (googleAccount) {
|
|
||||||
try {
|
try {
|
||||||
const data = googleAccount.account_data || {};
|
const deviceResult = await DeviceMediaService.getPhotos(100);
|
||||||
const googleAuth = new GoogleAUTH();
|
if (deviceResult.mediaItems.length) {
|
||||||
googleAuth.accessToken = data.accessToken || null;
|
newMedia.push(...deviceResult.mediaItems);
|
||||||
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',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
setDeviceEndCursor(deviceResult.endCursor);
|
||||||
|
setDeviceHasMore(deviceResult.hasNextPage);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Google Photos fetch error:', err);
|
console.error('Device media fetch error:', err);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// iCloud (placeholder)
|
// ─── NAS fetch ───
|
||||||
const iosAccount = linkedAccounts.find((a) => a.service_type === 'ICLOUD_PHOTOS');
|
|
||||||
if (iosAccount) {
|
|
||||||
console.log('iCloud: placeholder — would fetch photos here');
|
|
||||||
}
|
|
||||||
|
|
||||||
// NAS fetch
|
|
||||||
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
|
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
|
||||||
if (nasAccount) {
|
if (nasAccount) {
|
||||||
try {
|
try {
|
||||||
@@ -106,8 +83,15 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const combined = [...mediaFromDb, ...newMedia];
|
// ─── iCloud (placeholder) ───
|
||||||
const unique = Array.from(new Map(combined.map((m) => [m.source_id || m.id || m.path, m])).values());
|
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);
|
setAllMedia(unique);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading media:', err);
|
console.error('Error loading media:', err);
|
||||||
@@ -127,26 +111,20 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadMorePhotos = async () => {
|
const loadMorePhotos = async () => {
|
||||||
if (loadingMore || !googleNextPage) return;
|
if (loadingMore || !deviceHasMore) return;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
const result = await DeviceMediaService.getPhotos(100, deviceEndCursor);
|
||||||
if (googleAccount) {
|
if (result.mediaItems.length) {
|
||||||
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) => {
|
setAllMedia((prev) => {
|
||||||
const combined = [...prev, ...photos.mediaItems];
|
const combined = [...prev, ...result.mediaItems];
|
||||||
return Array.from(new Map(combined.map((m) => [m.source_id || m.id || m.path, m])).values());
|
return Array.from(
|
||||||
|
new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values()
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setGoogleNextPage(photos?.nextPageToken || null);
|
setDeviceEndCursor(result.endCursor);
|
||||||
}
|
setDeviceHasMore(result.hasNextPage);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading more photos:', err);
|
console.error('Error loading more photos:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -154,8 +132,8 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getServiceIcon = (type) => ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
|
||||||
const getServiceName = (type) => ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
|
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
|
||||||
|
|
||||||
if (loading && allMedia.length === 0) {
|
if (loading && allMedia.length === 0) {
|
||||||
return (
|
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 = () => (
|
const FilterChips = () => (
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterRow} style={styles.filterContainer}>
|
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterRow} style={styles.filterContainer}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -174,15 +162,15 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
>
|
>
|
||||||
<Text style={[styles.chipText, selectedFilter === 'all' && { color: theme.primary, fontWeight: '600' }]}>All</Text>
|
<Text style={[styles.chipText, selectedFilter === 'all' && { color: theme.primary, fontWeight: '600' }]}>All</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{linkedAccounts.map((acc) => (
|
{sourceFilters.map((f) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={acc.id}
|
key={f.key}
|
||||||
style={[styles.chip, { backgroundColor: selectedFilter === acc.service_type ? theme.chipActiveBg : theme.chipBg }]}
|
style={[styles.chip, { backgroundColor: selectedFilter === f.key ? theme.chipActiveBg : theme.chipBg }]}
|
||||||
onPress={() => setSelectedFilter(acc.service_type)}
|
onPress={() => setSelectedFilter(f.key)}
|
||||||
>
|
>
|
||||||
<Text style={styles.chipIcon}>{getServiceIcon(acc.service_type)}</Text>
|
<Text style={styles.chipIcon}>{f.icon}</Text>
|
||||||
<Text style={[styles.chipText, selectedFilter === acc.service_type && { color: theme.primary, fontWeight: '600' }]}>
|
<Text style={[styles.chipText, selectedFilter === f.key && { color: theme.primary, fontWeight: '600' }]}>
|
||||||
{getServiceName(acc.service_type)}
|
{f.name}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
@@ -198,14 +186,14 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
onMenuPress={() => navigation.navigate('Settings', { userId })}
|
onMenuPress={() => navigation.navigate('Settings', { userId })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{linkedAccounts.length > 0 && <FilterChips />}
|
<FilterChips />
|
||||||
|
|
||||||
{filteredMedia.length === 0 ? (
|
{filteredMedia.length === 0 ? (
|
||||||
<View style={styles.centerContainer}>
|
<View style={styles.centerContainer}>
|
||||||
<Text style={{ fontSize: 56, marginBottom: 16 }}>📸</Text>
|
<Text style={{ fontSize: 56, marginBottom: 16 }}>📸</Text>
|
||||||
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Photos Yet</Text>
|
<Text style={[styles.emptyTitle, { color: theme.text }]}>No Photos Yet</Text>
|
||||||
<Text style={[styles.emptyText, { color: theme.textSecondary }]}>
|
<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>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.retryButton, { backgroundColor: theme.primary }]}
|
style={[styles.retryButton, { backgroundColor: theme.primary }]}
|
||||||
|
|||||||
@@ -76,12 +76,14 @@ const SettingsScreen = ({ navigation, route }) => {
|
|||||||
const checkSingleHealth = async (acc) => {
|
const checkSingleHealth = async (acc) => {
|
||||||
try {
|
try {
|
||||||
if (acc.service_type === 'GOOGLE_PHOTOS') {
|
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();
|
const gAuth = new GoogleAUTH();
|
||||||
gAuth.accessToken = acc.account_data?.accessToken;
|
gAuth.accessToken = acc.account_data?.accessToken;
|
||||||
gAuth.refreshToken = acc.account_data?.refreshToken;
|
gAuth.refreshToken = acc.account_data?.refreshToken;
|
||||||
gAuth.expiresAt = Date.now() + 3600000;
|
gAuth.expiresAt = acc.account_data?.expiresAt || 0;
|
||||||
const res = await gAuth.getPhotos(1);
|
const info = await gAuth.getUserInfo();
|
||||||
return res?.mediaItems !== undefined ? 'healthy' : 'error';
|
return info?.email ? 'healthy' : 'error';
|
||||||
}
|
}
|
||||||
if (acc.service_type === 'NAS') {
|
if (acc.service_type === 'NAS') {
|
||||||
const nasAuth = new NASAUTH();
|
const nasAuth = new NASAUTH();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
Reference in new issue
Block a user