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
+313
-94
No files matched your search
@@ -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,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**
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 }]}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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