mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
feat: implement Google Photos import functionality and save media items to the database
This commit is contained in:
1 parent
5c096fbab4
commit
04046702d3
2 files changed
+149
-68
No files matched your search
+118
-68
@@ -11,6 +11,7 @@ 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';
|
||||||
import MediaViewer from '../components/MediaViewer';
|
import MediaViewer from '../components/MediaViewer';
|
||||||
|
import { logError } from '../services/ErrorHandler';
|
||||||
import { TouchableOpacity } from 'react-native';
|
import { TouchableOpacity } from 'react-native';
|
||||||
|
|
||||||
const HomeScreen = ({ navigation, route }) => {
|
const HomeScreen = ({ navigation, route }) => {
|
||||||
@@ -30,6 +31,7 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
const [googleNextPage, setGoogleNextPage] = useState(null);
|
const [googleNextPage, setGoogleNextPage] = useState(null);
|
||||||
const [googleSessionId, setGoogleSessionId] = useState(null);
|
const [googleSessionId, setGoogleSessionId] = useState(null);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
@@ -71,57 +73,12 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
console.error('Device media fetch error:', err);
|
console.error('Device media fetch error:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Google Photos cloud (via Picker API — includes cloud-only photos) ───
|
// ─── Previously imported Google Photos (stored in DB) ───
|
||||||
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
|
||||||
if (googleAccount) {
|
|
||||||
try {
|
try {
|
||||||
const data = googleAccount.account_data || {};
|
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
|
||||||
const googleAuth = new GoogleAUTH();
|
if (googleMedia?.length) newMedia.push(...googleMedia);
|
||||||
googleAuth.accessToken = data.accessToken || null;
|
|
||||||
googleAuth.refreshToken = data.refreshToken || null;
|
|
||||||
googleAuth.expiresAt = data.expiresAt || 0;
|
|
||||||
|
|
||||||
let photos = await googleAuth.getPhotos(100, null, null);
|
|
||||||
|
|
||||||
// If the stored token has stale scopes (missing photospicker scope),
|
|
||||||
// automatically re-authenticate to get a fresh token and retry.
|
|
||||||
if (photos?.error?.code === 403) {
|
|
||||||
console.log('Stored token missing Picker scope — re-authenticating…');
|
|
||||||
try {
|
|
||||||
await googleAuth.revokeAccess();
|
|
||||||
const fresh = await googleAuth.authenticate();
|
|
||||||
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
|
|
||||||
accessToken: fresh.accessToken,
|
|
||||||
refreshToken: fresh.refreshToken || null,
|
|
||||||
expiresAt: googleAuth.expiresAt,
|
|
||||||
email: fresh.email || data.email || 'google-user',
|
|
||||||
});
|
|
||||||
photos = await googleAuth.getPhotos(100, null, null);
|
|
||||||
} catch (authErr) {
|
|
||||||
console.warn('Google re-auth failed:', authErr.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (photos?.error) {
|
|
||||||
console.warn('Google Photos cloud fetch failed:', photos.error.message);
|
|
||||||
} else {
|
|
||||||
if (photos?.mediaItems?.length) newMedia.push(...photos.mediaItems);
|
|
||||||
setGoogleNextPage(photos?.nextPageToken || null);
|
|
||||||
setGoogleSessionId(photos?.sessionId || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist any refreshed tokens back to DB
|
|
||||||
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) {
|
} catch (err) {
|
||||||
console.error('Google Photos cloud fetch error:', err);
|
console.error('DB Google media fetch error:', err);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── NAS fetch ───
|
// ─── NAS fetch ───
|
||||||
@@ -145,7 +102,7 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
console.log('iCloud: placeholder — would fetch photos here');
|
console.log('iCloud: placeholder — would fetch photos here');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate by source_id (Google Photos cloud items may overlap with device items by filename)
|
// Deduplicate
|
||||||
const unique = Array.from(
|
const unique = Array.from(
|
||||||
new Map(newMedia.map((m) => [m.source_id || m.id || m.local_path, m])).values()
|
new Map(newMedia.map((m) => [m.source_id || m.id || m.local_path, m])).values()
|
||||||
);
|
);
|
||||||
@@ -158,6 +115,76 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Import from Google Photos (opens picker in browser) ───
|
||||||
|
const importGooglePhotos = async () => {
|
||||||
|
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||||
|
if (!googleAccount) {
|
||||||
|
console.warn('No Google account linked');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const data = googleAccount.account_data || {};
|
||||||
|
const googleAuth = new GoogleAUTH();
|
||||||
|
googleAuth.accessToken = data.accessToken || null;
|
||||||
|
googleAuth.refreshToken = data.refreshToken || null;
|
||||||
|
googleAuth.expiresAt = data.expiresAt || 0;
|
||||||
|
|
||||||
|
let result = await googleAuth.pickPhotos();
|
||||||
|
|
||||||
|
// If 403 (stale scopes), auto re-auth and retry
|
||||||
|
if (result?.error?.code === 403) {
|
||||||
|
console.log('Stale token — re-authenticating…');
|
||||||
|
await googleAuth.revokeAccess();
|
||||||
|
const fresh = await googleAuth.authenticate();
|
||||||
|
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
|
||||||
|
accessToken: fresh.accessToken,
|
||||||
|
refreshToken: fresh.refreshToken || null,
|
||||||
|
expiresAt: googleAuth.expiresAt,
|
||||||
|
email: fresh.email || data.email || 'google-user',
|
||||||
|
});
|
||||||
|
result = await googleAuth.pickPhotos();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
logError('Import Google Photos', result.error.message);
|
||||||
|
} else if (result?.mediaItems?.length) {
|
||||||
|
// Save imported items to DB so they persist across app restarts
|
||||||
|
for (const item of result.mediaItems) {
|
||||||
|
await DatabaseService.saveMediaItem(userId, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
setGoogleNextPage(result.nextPageToken || null);
|
||||||
|
setGoogleSessionId(result.sessionId || null);
|
||||||
|
|
||||||
|
// Merge into current view
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Imported ${result.mediaItems.length} photos from Google`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist refreshed tokens
|
||||||
|
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) {
|
||||||
|
logError('Import Google Photos', err);
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filterMedia = () => {
|
const filterMedia = () => {
|
||||||
let filtered = allMedia;
|
let filtered = allMedia;
|
||||||
if (selectedFilter !== 'all') {
|
if (selectedFilter !== 'all') {
|
||||||
@@ -169,16 +196,14 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
|
|
||||||
const loadMorePhotos = async () => {
|
const loadMorePhotos = async () => {
|
||||||
if (loadingMore) return;
|
if (loadingMore) return;
|
||||||
// Load more from whichever source has more pages
|
|
||||||
const canLoadDevice = deviceHasMore;
|
const canLoadDevice = deviceHasMore;
|
||||||
const canLoadGoogle = !!googleNextPage;
|
const canLoadGoogle = !!googleNextPage && !!googleSessionId;
|
||||||
if (!canLoadDevice && !canLoadGoogle) return;
|
if (!canLoadDevice && !canLoadGoogle) return;
|
||||||
|
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const moreMedia = [];
|
const moreMedia = [];
|
||||||
|
|
||||||
// More device photos
|
|
||||||
if (canLoadDevice) {
|
if (canLoadDevice) {
|
||||||
const result = await DeviceMediaService.getPhotos(100, deviceEndCursor);
|
const result = await DeviceMediaService.getPhotos(100, deviceEndCursor);
|
||||||
if (result.mediaItems.length) moreMedia.push(...result.mediaItems);
|
if (result.mediaItems.length) moreMedia.push(...result.mediaItems);
|
||||||
@@ -186,7 +211,6 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
setDeviceHasMore(result.hasNextPage);
|
setDeviceHasMore(result.hasNextPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// More Google Photos cloud photos (reuse the Picker session)
|
|
||||||
if (canLoadGoogle) {
|
if (canLoadGoogle) {
|
||||||
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||||
if (googleAccount) {
|
if (googleAccount) {
|
||||||
@@ -196,20 +220,15 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
googleAuth.refreshToken = data.refreshToken || null;
|
googleAuth.refreshToken = data.refreshToken || null;
|
||||||
googleAuth.expiresAt = data.expiresAt || 0;
|
googleAuth.expiresAt = data.expiresAt || 0;
|
||||||
|
|
||||||
const photos = await googleAuth.getPhotos(100, googleNextPage, googleSessionId);
|
const photos = await googleAuth.getMorePhotos(100, googleNextPage, googleSessionId);
|
||||||
if (photos?.mediaItems?.length) moreMedia.push(...photos.mediaItems);
|
if (photos?.mediaItems?.length) {
|
||||||
|
moreMedia.push(...photos.mediaItems);
|
||||||
|
for (const item of photos.mediaItems) {
|
||||||
|
await DatabaseService.saveMediaItem(userId, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
setGoogleNextPage(photos?.nextPageToken || null);
|
setGoogleNextPage(photos?.nextPageToken || null);
|
||||||
if (photos?.sessionId) setGoogleSessionId(photos.sessionId);
|
if (photos?.sessionId) setGoogleSessionId(photos.sessionId);
|
||||||
|
|
||||||
// Persist refreshed tokens
|
|
||||||
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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +250,8 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
const getServiceIcon = (type) => ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[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);
|
const getServiceName = (type) => ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
|
||||||
|
|
||||||
|
const hasGoogleAccount = linkedAccounts.some((a) => a.service_type === 'GOOGLE_PHOTOS');
|
||||||
|
|
||||||
if (loading && allMedia.length === 0) {
|
if (loading && allMedia.length === 0) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
|
<View style={[styles.centerContainer, { backgroundColor: theme.background }]}>
|
||||||
@@ -284,12 +305,33 @@ const HomeScreen = ({ navigation, route }) => {
|
|||||||
|
|
||||||
<FilterChips />
|
<FilterChips />
|
||||||
|
|
||||||
|
{/* Import from Google Photos button */}
|
||||||
|
{hasGoogleAccount && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.importButton, { backgroundColor: theme.primary }]}
|
||||||
|
onPress={importGooglePhotos}
|
||||||
|
disabled={importing}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{importing ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.importIcon}>📷</Text>
|
||||||
|
<Text style={styles.importText}>Import from Google Photos</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
{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 }]}>
|
||||||
Grant media library permission or take some photos to get started
|
{hasGoogleAccount
|
||||||
|
? 'Tap "Import from Google Photos" above to select cloud photos,\nor grant media library permission to see on-device photos.'
|
||||||
|
: '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 }]}
|
||||||
@@ -337,9 +379,17 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
chipIcon: { fontSize: 11 },
|
chipIcon: { fontSize: 11 },
|
||||||
chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' },
|
chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' },
|
||||||
|
// Import button
|
||||||
|
importButton: {
|
||||||
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
|
||||||
|
marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16,
|
||||||
|
borderRadius: 12, gap: 8, minHeight: 42,
|
||||||
|
},
|
||||||
|
importIcon: { fontSize: 16 },
|
||||||
|
importText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
||||||
// Empty
|
// Empty
|
||||||
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
|
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
|
||||||
emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20 },
|
emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 },
|
||||||
retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
|
retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
|
||||||
retryButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
retryButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -239,6 +239,37 @@ const DatabaseService = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a normalised media item object to the DB.
|
||||||
|
* Skips duplicates (same user_id + source_id).
|
||||||
|
*/
|
||||||
|
saveMediaItem: async (userId, item) => {
|
||||||
|
try {
|
||||||
|
await DatabaseService.ensureDb();
|
||||||
|
if (!userId || !item.source_service) return;
|
||||||
|
|
||||||
|
// Check for existing entry to avoid duplicates
|
||||||
|
if (item.source_id) {
|
||||||
|
const existing = await db.getFirstAsync(
|
||||||
|
'SELECT id FROM media WHERE user_id = ? AND source_id = ?',
|
||||||
|
[userId, item.source_id]
|
||||||
|
);
|
||||||
|
if (existing) return existing.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.runAsync(
|
||||||
|
`INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[userId, item.source_service, item.source_id || null, item.title || null,
|
||||||
|
item.description || null, item.local_path || null, item.remote_path || null,
|
||||||
|
item.media_type || null, item.created_date || null]
|
||||||
|
);
|
||||||
|
return result.lastInsertRowId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving media item:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
getMediaByUser: async (userId) => {
|
getMediaByUser: async (userId) => {
|
||||||
try {
|
try {
|
||||||
await DatabaseService.ensureDb();
|
await DatabaseService.ensureDb();
|
||||||
|
|||||||
Reference in new issue
Block a user