Merge pull request #1 from mudabbir-ahmad/Dev

Dev
This commit is contained in:
bobbert authored and GitHub committed 2026-02-27 14:05:39 +00:00
commit b09c49b46a
11 files changed
+1028 -475

No files matched your search

+68 -43
View File
@@ -1,27 +1,26 @@
// Google Photos API Test // Google Photos Picker API Test
// //
// Usage: // Usage:
// node TEST/testGooglePhotosAPI.js <ACCESS_TOKEN> // node TEST/testGooglePhotosAPI.js <ACCESS_TOKEN>
// GOOGLE_ACCESS_TOKEN=ya29.xxx node TEST/testGooglePhotosAPI.js // GOOGLE_ACCESS_TOKEN=ya29.xxx node TEST/testGooglePhotosAPI.js
// //
// The token must have been granted the scope:
// https://www.googleapis.com/auth/photospicker.mediaitems.readonly
//
// Getting a token from OAuth Playground (https://developers.google.com/oauthplayground): // Getting a token from OAuth Playground (https://developers.google.com/oauthplayground):
// 1. Click the gear icon → check "Use your own OAuth credentials" // 1. Click the gear icon → check "Use your own OAuth credentials"
// 2. Enter your Client ID and Client Secret from Google Cloud Console // 2. Enter your Client ID and Client Secret from Google Cloud Console
// 3. In the left panel select ALL of these scopes: // 3. In "Input your own scopes" enter:
// https://www.googleapis.com/auth/photoslibrary // https://www.googleapis.com/auth/photospicker.mediaitems.readonly
// https://www.googleapis.com/auth/photoslibrary.appendonly
// https://www.googleapis.com/auth/userinfo.email
// https://www.googleapis.com/auth/userinfo.profile
// 4. Click "Authorize APIs" and sign in with your test Google account // 4. Click "Authorize APIs" and sign in with your test Google account
// 5. Click "Exchange authorization code for tokens" // 5. Click "Exchange authorization code for tokens"
// 6. Copy the Access token (starts with ya29.) and pass it here // 6. Copy the Access token (starts with ya29.) and pass it here
//
// Note: if you only see 403 PERMISSION_DENIED, the token was issued without
// the photoslibrary scope — go back and re-authorise with all scopes above.
const API_BASE = 'https://photoslibrary.googleapis.com/v1'; const PICKER_BASE = 'https://photospicker.googleapis.com/v1';
const USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo'; const USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
function delay(ms) { return new Promise((r) => setTimeout(r, ms)); }
async function main() { async function main() {
const token = process.argv[2] || process.env.GOOGLE_ACCESS_TOKEN; const token = process.argv[2] || process.env.GOOGLE_ACCESS_TOKEN;
@@ -30,61 +29,87 @@ async function main() {
console.error('Usage: node TEST/testGooglePhotosAPI.js <ACCESS_TOKEN>'); console.error('Usage: node TEST/testGooglePhotosAPI.js <ACCESS_TOKEN>');
console.error('Or set: GOOGLE_ACCESS_TOKEN=ya29.xxx node TEST/testGooglePhotosAPI.js'); console.error('Or set: GOOGLE_ACCESS_TOKEN=ya29.xxx node TEST/testGooglePhotosAPI.js');
console.error('\nTo obtain a token go to https://developers.google.com/oauthplayground'); console.error('\nTo obtain a token go to https://developers.google.com/oauthplayground');
console.error('and authorise the Photos Library API v1 scope.\n'); console.error('and authorise the photospicker.mediaitems.readonly scope.\n');
process.exit(1); process.exit(1);
} }
const headers = { Authorization: `Bearer ${token}` }; const headers = { Authorization: `Bearer ${token}` };
console.log('\n--- Google Photos API Test ---\n'); console.log('\n--- Google Photos Picker API Test ---\n');
// Test 1 — User info // Test 1 — User info
console.log('[1] Fetching user info...'); console.log('[1] Fetching user info...');
try { try {
const res = await fetch(USERINFO_URL, { headers }); const res = await fetch(USERINFO_URL, { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); if (!res.ok) {
const user = await res.json(); console.log(` Failed: HTTP ${res.status}: ${await res.text()}\n`);
console.log(` Authenticated as: ${user.email} (${user.name})\n`); } else {
const user = await res.json();
console.log(` Authenticated as: ${user.email} (${user.name})\n`);
}
} catch (err) { } catch (err) {
console.log(` Failed: ${err.message}\n`); console.log(` Failed: ${err.message}\n`);
} }
// Test 2 — Search media items (replaces deprecated GET /mediaItems) // Test 2 — Create Picker session
console.log('[2] Searching media items (first 10)...'); console.log('[2] Creating Picker session...');
let sessionId = null;
try { try {
const res = await fetch(`${API_BASE}/mediaItems:search`, { const res = await fetch(`${PICKER_BASE}/sessions`, {
method: 'POST', method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' }, headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ pageSize: 10 }), body: JSON.stringify({}),
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); if (!res.ok) {
const data = await res.json(); console.log(` Failed: HTTP ${res.status}: ${await res.text()}\n`);
const items = data.mediaItems || []; } else {
console.log(` Found ${items.length} items`); const session = await res.json();
items.slice(0, 5).forEach((item, i) => { sessionId = session.id;
console.log(` ${i + 1}. ${item.filename} (${item.mimeType})`); console.log(` Session created: ${sessionId}`);
}); console.log(` mediaItemsSet: ${session.mediaItemsSet}\n`);
if (items.length > 5) console.log(` ... and ${items.length - 5} more`);
if (data.nextPageToken) console.log(' More pages available'); // Wait for session to be ready
console.log(); if (!session.mediaItemsSet) {
console.log(' Waiting for session to become ready...');
for (let i = 0; i < 10; i++) {
await delay(1500);
const pollRes = await fetch(`${PICKER_BASE}/sessions/${sessionId}`, { headers });
const pollData = await pollRes.json();
if (pollData.mediaItemsSet) {
console.log(' Session is ready!\n');
break;
}
if (i === 9) console.log(' Session timed out.\n');
}
}
}
} catch (err) { } catch (err) {
console.log(` Failed: ${err.message}\n`); console.log(` Failed: ${err.message}\n`);
} }
// Test 3 — List albums // Test 3 — List media items from session
console.log('[3] Listing albums...'); if (sessionId) {
try { console.log('[3] Fetching media items from session...');
const res = await fetch(`${API_BASE}/albums?pageSize=10`, { headers }); try {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); const params = new URLSearchParams({ sessionId, pageSize: '10' });
const data = await res.json(); const res = await fetch(`${PICKER_BASE}/mediaItems?${params.toString()}`, { headers });
const albums = data.albums || []; if (!res.ok) {
console.log(` Found ${albums.length} albums`); console.log(` Failed: HTTP ${res.status}: ${await res.text()}\n`);
albums.slice(0, 5).forEach((album, i) => { } else {
console.log(` ${i + 1}. "${album.title}" - ${album.mediaItemsCount || 0} items`); const data = await res.json();
}); const items = data.mediaItems || [];
console.log(); console.log(` Found ${items.length} items`);
} catch (err) { items.slice(0, 5).forEach((item, i) => {
console.log(` Failed: ${err.message}\n`); const file = item.mediaFile || {};
console.log(` ${i + 1}. ${file.filename || '(no name)'} (${file.mimeType || '?'})`);
});
if (items.length > 5) console.log(` ... and ${items.length - 5} more`);
if (data.nextPageToken) console.log(' More pages available');
console.log();
}
} catch (err) {
console.log(` Failed: ${err.message}\n`);
}
} }
console.log('--- Tests complete ---\n'); console.log('--- Tests complete ---\n');
+3 -2
View File
@@ -13,7 +13,8 @@
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
}, },
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"bundleIdentifier": "com.bobthebob.massphotoapp"
}, },
"android": { "android": {
"package": "com.bobthebob.massphotoapp", "package": "com.bobthebob.massphotoapp",
@@ -35,4 +36,4 @@
} }
} }
} }
} }
+6 -6
View File
@@ -5,10 +5,11 @@
2. Click **Select a project** → **New Project** 2. Click **Select a project** → **New Project**
3. Name it and click **Create** 3. Name it and click **Create**
## Step 2: Enable the Photos Library API ## Step 2: Enable the Photos Picker 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 Picker API** → **Enable** it
3. ⚠️ This step is **required** — without it, all Photos API calls return 403 3. ⚠️ This step is **required** — without it, all Picker API calls fail
4. (The old "Photos Library API" is deprecated and no longer works)
## 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**
@@ -19,13 +20,12 @@
- **Developer contact**: your email - **Developer contact**: your email
4. **Scopes** → Add or Remove Scopes → add these: 4. **Scopes** → Add or Remove Scopes → add these:
- `openid` - `openid`
- `https://www.googleapis.com/auth/photoslibrary` - `https://www.googleapis.com/auth/photospicker.mediaitems.readonly`
- `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, - ⚠️ This is **critical** — if your email is not listed as a test user,
Google will silently strip the Photos scope from the granted token, Google will silently strip the Photos Picker 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**
+16 -15
View File
@@ -1,10 +1,13 @@
// Google Photos Library API wrapper — uses the provided access token getter for authenticated requests // Google Photos Picker API wrapper — replaces the deprecated Photos Library API.
// Uses sessions to access user photos from Google Photos cloud.
const GooglePhotosAPI = (getAccessToken) => { const GooglePhotosAPI = (getAccessToken) => {
const PICKER_ROOT = 'https://photospicker.googleapis.com/v1';
const callFetch = async (endpoint, method = 'GET', dataObj = null) => { const callFetch = async (endpoint, method = 'GET', dataObj = null) => {
const token = await getAccessToken(); const token = await getAccessToken();
if (!token) return { isSuccess: false, message: 'Missing access token' }; if (!token) return { isSuccess: false, message: 'Missing access token' };
let requestObj = { const requestObj = {
method, method,
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}; };
@@ -25,21 +28,19 @@ const GooglePhotosAPI = (getAccessToken) => {
} }
}; };
const API_ROOT = 'https://photoslibrary.googleapis.com/v1';
return { return {
listMediaItems: (pageSize = 25, pageToken) => { // Create a Picker session — must be called before listing items
const body = { pageSize }; createSession: () => callFetch(`${PICKER_ROOT}/sessions`, 'POST', {}),
if (pageToken) body.pageToken = pageToken;
return callFetch(`${API_ROOT}/mediaItems:search`, 'POST', body); // Poll session status
getSession: (sessionId) => callFetch(`${PICKER_ROOT}/sessions/${sessionId}`, 'GET'),
// List media items from a ready session
listMediaItems: (sessionId, pageSize = 25, pageToken) => {
const params = new URLSearchParams({ sessionId, pageSize: String(pageSize) });
if (pageToken) params.append('pageToken', pageToken);
return callFetch(`${PICKER_ROOT}/mediaItems?${params.toString()}`, 'GET');
}, },
getMediaItem: (mediaItemId) => callFetch(`${API_ROOT}/mediaItems/${mediaItemId}`, 'GET'),
searchMediaItems: (filters) => callFetch(`${API_ROOT}/mediaItems:search`, 'POST', filters),
createAlbum: (title) => callFetch(`${API_ROOT}/albums`, 'POST', { album: { title } }),
addItemsToAlbum: (albumId, mediaItemIds) =>
callFetch(`${API_ROOT}/albums/${albumId}:batchAddMediaItems`, 'POST', { mediaItemIds }),
removeItemsFromAlbum: (albumId, mediaItemIds) =>
callFetch(`${API_ROOT}/albums/${albumId}:batchRemoveMediaItems`, 'POST', { mediaItemIds }),
}; };
}; };
+399 -251
View File
@@ -1,15 +1,17 @@
import * as AuthSession from 'expo-auth-session'; import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser'; import * as WebBrowser from 'expo-web-browser';
import * as Application from 'expo-application'; import * as Application from 'expo-application';
import { File, Directory, Paths } from 'expo-file-system';
import { AuthServiceBase } from './AuthServiceBase'; import { AuthServiceBase } from './AuthServiceBase';
import { logError } from '../services/ErrorHandler';
WebBrowser.maybeCompleteAuthSession(); WebBrowser.maybeCompleteAuthSession();
// Android client ID — used for both auth requests and token exchange. // Android client ID — created in Google Cloud Console as "Android" type
// Created in Google Cloud Console as "Android" type with package name // with package name com.bobthebob.massphotoapp and the SHA-1 from
// com.bobthebob.massphotoapp and the SHA-1 from `eas credentials --platform android`. // `eas credentials --platform android`.
// Android clients are public (no client_secret needed) — PKCE verifies instead.
const ANDROID_CLIENT_ID = '313090284964-018uf4v1bmmrra09lvmpnuug2qs09047.apps.googleusercontent.com'; const ANDROID_CLIENT_ID = '313090284964-018uf4v1bmmrra09lvmpnuug2qs09047.apps.googleusercontent.com';
const IOS_CLIENT_ID = '313090284964-luka809nuc66bb4cg4gameovtqg8uoj1.apps.googleusercontent.com'; // Optional, just in case being kept here.
const GOOGLE_DISCOVERY = { const GOOGLE_DISCOVERY = {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
@@ -17,17 +19,20 @@ const GOOGLE_DISCOVERY = {
revocationEndpoint: 'https://oauth2.googleapis.com/revoke', revocationEndpoint: 'https://oauth2.googleapis.com/revoke',
}; };
// The old Photos Library API (photoslibrary.googleapis.com) was shut down
// in 2025. Google's replacement is the Photos Picker API which uses its
// own scope.
const SCOPES = [ const SCOPES = [
'openid', 'openid',
'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', 'https://www.googleapis.com/auth/photospicker.mediaitems.readonly',
]; ];
const PICKER_API = 'https://photospicker.googleapis.com/v1';
const CACHE_DIR = new Directory(Paths.cache, 'google_photos');
// 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
const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`; const REDIRECT_URI = `${Application.applicationId}:/oauthredirect`;
class GoogleAUTH extends AuthServiceBase { class GoogleAUTH extends AuthServiceBase {
@@ -37,110 +42,85 @@ class GoogleAUTH extends AuthServiceBase {
this.refreshToken = null; this.refreshToken = null;
this.expiresAt = null; this.expiresAt = null;
this.email = null; this.email = null;
this.idToken = null;
} }
// ───────────────────── Authentication ─────────────────────
async authenticate() { async authenticate() {
try { if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') {
if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') { console.warn('Google Auth: no Android client ID — using placeholder mode');
console.warn('Google Auth: no Android client ID — using placeholder mode'); this.accessToken = 'placeholder_google_token_' + Date.now();
this.accessToken = 'placeholder_google_token_' + Date.now(); this.refreshToken = 'placeholder_refresh_' + Date.now();
this.refreshToken = 'placeholder_refresh_' + Date.now(); this.expiresAt = Date.now() + 3600000;
this.expiresAt = Date.now() + 3600000; this.email = 'google-user@placeholder.com';
this.email = 'google-user@placeholder.com';
return {
success: true,
accessToken: this.accessToken,
refreshToken: this.refreshToken,
email: this.email,
};
}
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',
},
});
const result = await authRequest.promptAsync(GOOGLE_DISCOVERY);
if (result.type !== 'success') {
throw new Error('Google authentication was cancelled or failed');
}
// Exchange authorization code for tokens using the Android client ID.
// Android clients are public (no client secret required) — the PKCE
// code_verifier is used to verify the exchange instead.
const tokenBody = new URLSearchParams({
client_id: ANDROID_CLIENT_ID,
code: result.params.code,
code_verifier: authRequest.codeVerifier,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
});
const tokenResponse = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenBody.toString(),
});
const tokenData = await tokenResponse.json();
if (tokenData.error) {
throw new Error(tokenData.error_description || tokenData.error);
}
this.accessToken = tokenData.access_token;
this.refreshToken = tokenData.refresh_token || null;
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';
return { return {
success: true, success: true,
accessToken: this.accessToken, accessToken: this.accessToken,
refreshToken: this.refreshToken, refreshToken: this.refreshToken,
email: this.email, email: this.email,
}; };
} catch (error) {
console.error('Google authentication error:', error);
throw error;
} }
console.log('OAuth redirect URI:', REDIRECT_URI);
console.log('Requesting scopes:', SCOPES.join(' '));
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',
},
});
const result = await authRequest.promptAsync(GOOGLE_DISCOVERY);
if (result.type !== 'success') {
const err = new Error('Google authentication was cancelled or failed');
logError('Google Auth', err);
throw err;
}
const tokenBody = new URLSearchParams({
client_id: ANDROID_CLIENT_ID,
code: result.params.code,
code_verifier: authRequest.codeVerifier,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
});
const tokenResponse = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenBody.toString(),
});
const tokenData = await tokenResponse.json();
if (tokenData.error) {
const err = new Error(tokenData.error_description || tokenData.error);
logError('Google Token Exchange', err);
throw err;
}
this.accessToken = tokenData.access_token;
this.refreshToken = tokenData.refresh_token || null;
this.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000;
console.log('Google token granted scopes:', tokenData.scope || '(none returned)');
const userInfo = await this.fetchUserInfo();
this.email = userInfo?.email || 'google-user';
return {
success: true,
accessToken: this.accessToken,
refreshToken: this.refreshToken,
email: this.email,
};
} }
async fetchUserInfo() { async fetchUserInfo() {
@@ -162,29 +142,28 @@ class GoogleAUTH extends AuthServiceBase {
} }
async refreshAccessToken() { async refreshAccessToken() {
try { const body = new URLSearchParams({
const body = new URLSearchParams({ client_id: ANDROID_CLIENT_ID,
client_id: ANDROID_CLIENT_ID, refresh_token: this.refreshToken,
refresh_token: this.refreshToken, grant_type: 'refresh_token',
grant_type: 'refresh_token', });
});
const response = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, { const response = await fetch(GOOGLE_DISCOVERY.tokenEndpoint, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(), body: body.toString(),
}); });
const data = await response.json(); const data = await response.json();
if (data.error) throw new Error(data.error_description || data.error); if (data.error) {
const err = new Error(data.error_description || data.error);
this.accessToken = data.access_token; logError('Token Refresh', err);
this.expiresAt = Date.now() + (data.expires_in || 3600) * 1000; throw err;
return this.accessToken;
} catch (error) {
console.error('Token refresh error:', error);
throw error;
} }
this.accessToken = data.access_token;
this.expiresAt = Date.now() + (data.expires_in || 3600) * 1000;
return this.accessToken;
} }
async revokeAccess() { async revokeAccess() {
@@ -195,13 +174,12 @@ class GoogleAUTH extends AuthServiceBase {
}); });
} }
} catch (err) { } catch (err) {
console.error('Revoke error:', err); logError('Revoke', err);
} }
this.accessToken = null; this.accessToken = null;
this.refreshToken = null; this.refreshToken = null;
this.expiresAt = null; this.expiresAt = null;
this.email = null; this.email = null;
this.idToken = null;
return { success: true }; return { success: true };
} }
@@ -210,154 +188,324 @@ class GoogleAUTH extends AuthServiceBase {
return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' }; return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' };
} }
async getPhotos(pageSize = 50, pageToken = null) { // ───────────────── Photos Picker API ──────────────────
try { //
const token = await this.getAccessToken(); // The Picker API is interactive — the user must open the pickerUri in a
// browser to select which photos to share with the app. Once the user
// finishes selecting, mediaItemsSet becomes true and we can fetch items.
//
// Flow:
// 1. POST /v1/sessions → get { id, pickerUri }
// 2. Open pickerUri in browser → user selects photos → browser closes
// 3. Poll GET /v1/sessions/{id} until mediaItemsSet = true
// 4. GET /v1/mediaItems?sessionId={id}&pageSize=N → selected items
// Use the mediaItems:search endpoint (POST) instead of the deprecated /**
// GET /v1/mediaItems which Google shut down in 2025. * Create a new Picker session.
// An empty filters body returns ALL photos, ordered by creation time. * Returns the full session object { id, pickerUri, mediaItemsSet, ... }
const body = { pageSize }; * or { error: { code, message } } on failure.
if (pageToken) body.pageToken = pageToken; */
async _createPickerSession() {
const token = await this.getAccessToken();
const res = await fetch(`${PICKER_API}/sessions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
const url = 'https://photoslibrary.googleapis.com/v1/mediaItems:search'; if (!res.ok) {
const response = await fetch(url, { const body = await res.text();
method: 'POST', logError('Picker Session Create', `${res.status}: ${body}`);
headers: { return { error: { code: res.status, message: body } };
Authorization: `Bearer ${token}`, }
'Content-Type': 'application/json',
}, return await res.json();
body: JSON.stringify(body), }
/**
* Poll a session until mediaItemsSet becomes true.
* Waits up to ~30 seconds (user needs time to select photos).
*/
async _waitForSession(sessionId) {
const token = await this.getAccessToken();
const maxAttempts = 20;
const delayMs = 1500;
for (let i = 0; i < maxAttempts; i++) {
const res = await fetch(`${PICKER_API}/sessions/${sessionId}`, {
headers: { Authorization: `Bearer ${token}` },
}); });
if (!response.ok) { if (!res.ok) {
let errorBody = ''; logError('Picker Session Poll', `${res.status}: ${await res.text()}`);
let errorJson = null; return 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 };
} }
const data = await response.json(); const session = await res.json();
return { if (session.mediaItemsSet) return session;
mediaItems: (data.mediaItems || []).map((item) => ({
source_service: 'GOOGLE_PHOTOS', await new Promise((resolve) => setTimeout(resolve, delayMs));
source_id: item.id, }
title: item.filename || 'Untitled',
description: item.description || '', console.warn('Picker session timed out waiting for user selection');
local_path: item.baseUrl ? `${item.baseUrl}=w400-h400` : null, return null;
remote_path: item.productUrl || '', }
media_type: item.mimeType?.startsWith('video') ? 'video' : 'photo',
created_date: item.mediaMetadata?.creationTime || null, /**
})), * Fetch media items from a ready Picker session.
nextPageToken: data.nextPageToken || null, */
}; async _fetchPickerMediaItems(sessionId, pageSize = 100, pageToken = null) {
} catch (error) { const token = await this.getAccessToken();
console.error('Get photos error:', error); const params = new URLSearchParams({
sessionId,
pageSize: String(pageSize),
});
if (pageToken) params.append('pageToken', pageToken);
const res = await fetch(`${PICKER_API}/mediaItems?${params.toString()}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const body = await res.text();
logError('Picker MediaItems', `${res.status}: ${body}`);
return { mediaItems: [], nextPageToken: null }; return { mediaItems: [], nextPageToken: null };
} }
const data = await res.json();
// Debug: log raw response structure
if (data.mediaItems?.length) {
console.log('Picker raw item[0]:', JSON.stringify(data.mediaItems[0], null, 2));
} else {
console.log('Picker returned no mediaItems. Keys:', Object.keys(data));
}
return data;
} }
async getAlbums(pageSize = 50, pageToken = null) { /**
try { * Normalise Picker API media items to the app's standard format.
const token = await this.getAccessToken(); *
const params = new URLSearchParams({ pageSize: String(pageSize) }); * The Picker API response shape per item:
if (pageToken) params.append('pageToken', pageToken); * { id, mediaFile: { baseUrl, mimeType, filename, mediaFileMetadata: { ... } } }
*
* baseUrl requires an Authorization header to fetch the actual image bytes.
*/
_normalisePickerItems(items) {
if (!items?.length) return [];
const response = await fetch( return items.map((item, idx) => {
`https://photoslibrary.googleapis.com/v1/albums?${params.toString()}`, // The item may have mediaFile nested, or may be flat
{ headers: { Authorization: `Bearer ${token}` } } const file = item.mediaFile || item;
); const baseUrl = file.baseUrl || item.baseUrl || null;
const filename = file.filename || item.filename || 'Untitled';
const mimeType = file.mimeType || item.mimeType || null;
const isVideo = mimeType?.startsWith('video');
const creationTime = file.mediaFileMetadata?.creationTime
|| item.mediaFileMetadata?.creationTime
|| null;
if (!response.ok) { if (idx === 0) {
const errorBody = await response.text(); console.log(`Normalising item[0]: id=${item.id}, baseUrl=${baseUrl?.substring(0, 60)}…, filename=${filename}, mime=${mimeType}`);
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 }; return {
source_service: 'GOOGLE_PHOTOS',
source_id: item.id || file.id || `picker_${Date.now()}_${idx}`,
title: filename,
description: '',
local_path: null,
remote_path: baseUrl || '',
picker_base_url: baseUrl,
media_type: isVideo ? 'video' : 'photo',
mime_type: mimeType,
created_date: creationTime,
};
});
}
/**
* Download Picker images to local cache so <Image> can render them.
*
* The Picker API's baseUrl requires an Authorization header. React
* Native's <Image> can't add custom headers reliably, so we download
* each photo to a cache file and set local_path to the file:// URI.
*
* @param {Array} items — normalised items from _normalisePickerItems
* @returns {Array} — same items with local_path populated
*/
async downloadPickerPhotos(items) {
const token = await this.getAccessToken();
// Ensure cache directory exists
if (!CACHE_DIR.exists) {
CACHE_DIR.create({ intermediates: true });
} else {
// Clean up stale files from previous failed downloads (< 1KB = likely error pages)
try {
const entries = CACHE_DIR.list();
for (const entry of entries) {
if (entry instanceof File && entry.size < 1024) {
entry.delete();
}
}
} catch (_) { /* ignore cleanup errors */ }
}
const results = [];
for (const item of items) {
if (!item.picker_base_url) {
results.push(item);
continue;
}
try {
const safeId = (item.source_id || 'photo').replace(/[^a-zA-Z0-9_-]/g, '_');
const ext = item.mime_type?.includes('png') ? '.png'
: item.mime_type?.includes('gif') ? '.gif'
: item.mime_type?.includes('webp') ? '.webp'
: '.jpg';
const destFile = new File(CACHE_DIR, `${safeId}${ext}`);
// Skip download if already cached and non-empty
if (destFile.exists && destFile.size > 1024) {
results.push({ ...item, local_path: destFile.uri });
continue;
}
// The Picker API baseUrl is a direct download link when used with
// the Authorization header. No suffix needed.
console.log(`Downloading: ${item.title} (${item.picker_base_url.substring(0, 80)}…)`);
const downloaded = await File.downloadFileAsync(
item.picker_base_url,
destFile,
{ headers: { Authorization: `Bearer ${token}` }, idempotent: true }
);
console.log(` → uri=${downloaded.uri}, size=${downloaded.size}`);
if (downloaded.exists && downloaded.size > 1024) {
results.push({ ...item, local_path: downloaded.uri });
} else if (downloaded.exists && downloaded.size > 0) {
// Suspiciously small — check if it's an error page
try {
const peek = await downloaded.text();
if (peek.startsWith('<') || peek.startsWith('{')) {
logError('Download Picker Photo',
`Got error page for ${item.title}: ${peek.substring(0, 150)}`);
downloaded.delete();
results.push(item); // no local_path
} else {
results.push({ ...item, local_path: downloaded.uri });
}
} catch (_) {
// Can't read as text = probably binary = probably an image
results.push({ ...item, local_path: downloaded.uri });
}
} else {
logError('Download Picker Photo', `Empty file for ${item.title}`);
if (downloaded.exists) downloaded.delete();
results.push(item);
}
} catch (err) {
logError('Download Picker Photo', err);
results.push(item);
}
}
return results;
}
/**
* Full interactive flow: create session → open picker in browser →
* wait for user to select photos → fetch the selected items.
*
* @returns {{ mediaItems, nextPageToken, sessionId, error? }}
*/
async pickPhotos() {
try {
// 1. Create session
console.log('Creating Google Photos Picker session…');
const session = await this._createPickerSession();
if (session?.error) {
return {
mediaItems: [], nextPageToken: null, sessionId: null,
error: { code: session.error.code, message: session.error.message },
};
}
if (!session?.id || !session?.pickerUri) {
return {
mediaItems: [], nextPageToken: null, sessionId: null,
error: { code: 0, message: 'Failed to create Picker session' },
};
}
console.log('Picker session created:', session.id);
console.log('Opening picker for user selection…');
// 2. Open the picker URI in a browser for the user to select photos
await WebBrowser.openBrowserAsync(session.pickerUri, {
dismissButtonStyle: 'done',
presentationStyle: 'pageSheet',
});
console.log('Browser closed — polling for selected photos…');
// 3. Poll until the user's selections are ready
const ready = await this._waitForSession(session.id);
if (!ready) {
return {
mediaItems: [], nextPageToken: null, sessionId: session.id,
error: { code: 0, message: 'No photos were selected or the picker was dismissed' },
};
}
// 4. Fetch selected items and download to local cache
const result = await this._fetchPickerMediaItems(session.id, 100, null);
let mediaItems = this._normalisePickerItems(result.mediaItems);
console.log(`Picker returned ${mediaItems.length} selected items, downloading…`);
mediaItems = await this.downloadPickerPhotos(mediaItems);
console.log(`Downloaded ${mediaItems.filter((m) => m.local_path).length} photos to cache`);
return {
mediaItems,
nextPageToken: result.nextPageToken || null,
sessionId: session.id,
};
} catch (error) { } catch (error) {
console.error('Get albums error:', error); logError('Pick Photos', error);
return { albums: [], nextPageToken: null }; return { mediaItems: [], nextPageToken: null, sessionId: null };
} }
} }
// NOTE: Upload requires the `photoslibrary.appendonly` scope which is not /**
// requested by default (read-only mode). Re-authenticate with upload scopes * Fetch more items from an existing Picker session (pagination).
// before calling this method. */
async uploadPhoto(filePath, filename, mimeType = 'image/jpeg') { async getMorePhotos(pageSize = 100, pageToken, sessionId) {
try { try {
const token = await this.getAccessToken(); if (!sessionId || !pageToken) {
return { mediaItems: [], nextPageToken: null, sessionId };
}
const uploadResponse = await fetch('https://photoslibrary.googleapis.com/v1/uploads', { const result = await this._fetchPickerMediaItems(sessionId, pageSize, pageToken);
method: 'POST', let mediaItems = this._normalisePickerItems(result.mediaItems);
headers: { mediaItems = await this.downloadPickerPhotos(mediaItems);
Authorization: `Bearer ${token}`,
'Content-Type': 'application/octet-stream',
'X-Goog-Upload-File-Name': filename,
'X-Goog-Upload-Protocol': 'raw',
},
body: await fetch(filePath).then((r) => r.blob()),
});
if (!uploadResponse.ok) throw new Error('Upload bytes failed'); return {
const uploadToken = await uploadResponse.text(); mediaItems,
nextPageToken: result.nextPageToken || null,
const createResponse = await fetch( sessionId,
'https://photoslibrary.googleapis.com/v1/mediaItems:batchCreate', };
{
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
newMediaItems: [{ simpleMediaItem: { uploadToken, fileName: filename } }],
}),
}
);
if (!createResponse.ok) throw new Error('Create media item failed');
return await createResponse.json();
} catch (error) { } catch (error) {
console.error('Upload photo error:', error); logError('Get More Photos', error);
throw error; return { mediaItems: [], nextPageToken: null, sessionId };
} }
} }
} }
+28 -17
View File
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useCallback } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { import {
Modal, View, Text, TouchableOpacity, Image, StyleSheet, Modal, View, Text, TouchableOpacity, Image, StyleSheet,
Dimensions, FlatList, Dimensions, FlatList,
@@ -57,22 +57,22 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
}, 100); }, 100);
}, [visible, media]); }, [visible, media]);
const onMainScroll = useCallback( const activeIndexRef = useRef(0);
(event) => {
const offsetX = event.nativeEvent.contentOffset.x; const onMainScroll = (event) => {
const idx = Math.round(offsetX / SCREEN_WIDTH); const offsetX = event.nativeEvent.contentOffset.x;
if (idx >= 0 && idx < mediaList.length && idx !== activeIndex) { const idx = Math.round(offsetX / SCREEN_WIDTH);
setActiveIndex(idx); if (idx >= 0 && idx < mediaList.length && idx !== activeIndexRef.current) {
setCurrentMedia(mediaList[idx]); activeIndexRef.current = idx;
if (thumbListRef.current) { setActiveIndex(idx);
try { setCurrentMedia(mediaList[idx]);
thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 }); if (thumbListRef.current) {
} catch (e) { /* ignore */ } try {
} thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
} catch (_) { /* ignore */ }
} }
}, }
[activeIndex, mediaList] };
);
const onThumbPress = (index) => { const onThumbPress = (index) => {
setActiveIndex(index); setActiveIndex(index);
@@ -189,9 +189,20 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
<View style={styles.row}> <View style={styles.row}>
<Text style={styles.label}>Source</Text> <Text style={styles.label}>Source</Text>
<Text style={styles.value}> <Text style={styles.value}>
{getServiceIcon(displayMedia.source_service)} {getServiceName(displayMedia.source_service)} {displayMedia.sources && displayMedia.sources.length > 1
? displayMedia.sources.map((s) => `${getServiceIcon(s)} ${getServiceName(s)}`).join(' · ')
: `${getServiceIcon(displayMedia.source_service)} ${getServiceName(displayMedia.source_service)}`
}
</Text> </Text>
</View> </View>
{displayMedia.sources && displayMedia.sources.length > 1 && (
<View style={styles.row}>
<Text style={styles.label}>Available on</Text>
<Text style={[styles.value, { color: '#8ab4f8' }]}>
{displayMedia.sources.length} services
</Text>
</View>
)}
{displayMedia.created_date && ( {displayMedia.created_date && (
<View style={styles.row}> <View style={styles.row}>
<Text style={styles.label}>Date</Text> <Text style={styles.label}>Date</Text>
+37 -5
View File
@@ -21,7 +21,31 @@ const PhotoGrid = ({
const itemSize = (width - GRID_GAP * (columns + 1)) / columns; const itemSize = (width - GRID_GAP * (columns + 1)) / columns;
const getServiceIcon = (type) => const getServiceIcon = (type) =>
({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const renderSourceBadges = (item) => {
const sources = item.sources && item.sources.length > 0
? item.sources
: [item.source_service];
if (sources.length === 1) {
// Single source — simple badge
return (
<View style={styles.sourceBadge}>
<Text style={{ fontSize: 10 }}>{getServiceIcon(sources[0])}</Text>
</View>
);
}
// Multiple sources — show all icons with a subtle indicator
return (
<View style={styles.multiSourceBadge}>
{sources.map((src, i) => (
<Text key={src + i} style={{ fontSize: 9 }}>{getServiceIcon(src)}</Text>
))}
</View>
);
};
const renderItem = ({ item }) => ( const renderItem = ({ item }) => (
<TouchableOpacity <TouchableOpacity
@@ -30,7 +54,12 @@ const PhotoGrid = ({
activeOpacity={0.8} activeOpacity={0.8}
> >
{item.local_path ? ( {item.local_path ? (
<Image source={{ uri: item.local_path }} style={styles.gridImage} resizeMode="cover" /> <Image
source={{ uri: item.local_path }}
style={styles.gridImage}
resizeMode="cover"
onError={(e) => console.warn(`Image load failed [${item.source_service}]: ${item.local_path?.substring(0, 80)}`, e.nativeEvent?.error)}
/>
) : ( ) : (
<View style={[styles.placeholder, { backgroundColor: theme.borderLight }]}> <View style={[styles.placeholder, { backgroundColor: theme.borderLight }]}>
<Text style={{ fontSize: 28 }}>📷</Text> <Text style={{ fontSize: 28 }}>📷</Text>
@@ -41,9 +70,7 @@ const PhotoGrid = ({
<Text style={{ color: '#fff', fontSize: 20 }}>▶</Text> <Text style={{ color: '#fff', fontSize: 20 }}>▶</Text>
</View> </View>
)} )}
<View style={styles.sourceBadge}> {renderSourceBadges(item)}
<Text style={{ fontSize: 10 }}>{getServiceIcon(item.source_service)}</Text>
</View>
</TouchableOpacity> </TouchableOpacity>
); );
@@ -82,6 +109,11 @@ const styles = StyleSheet.create({
position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.5)', position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2, borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2,
}, },
multiSourceBadge: {
position: 'absolute', bottom: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.6)',
borderRadius: 8, paddingHorizontal: 4, paddingVertical: 2,
flexDirection: 'row', gap: 2,
},
}); });
export default PhotoGrid; export default PhotoGrid;
+1 -1
View File
@@ -13,7 +13,7 @@ const ALBUM_COLUMNS = 2;
const ALBUM_GAP = 12; const ALBUM_GAP = 12;
const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS; const albumSize = (width - ALBUM_GAP * (ALBUM_COLUMNS + 1)) / ALBUM_COLUMNS;
const CollectionsScreen = ({ route, navigation }) => { const CollectionsScreen = ({ route }) => {
const userId = route?.params?.userId; const userId = route?.params?.userId;
const { theme } = useTheme(); const { theme } = useTheme();
const [sections, setSections] = useState([]); const [sections, setSections] = useState([]);
+232 -19
View File
@@ -6,12 +6,71 @@ 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 DeviceMediaService from '../services/DeviceMediaService'; import DeviceMediaService from '../services/DeviceMediaService';
import GoogleAUTH from '../AUTH/GoogleAUTH';
import NASAUTH from '../AUTH/NASAUTH'; import NASAUTH from '../AUTH/NASAUTH';
import { File } from 'expo-file-system';
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';
/**
* Merge media items from different services.
* Items with the same filename (title) are merged into a single entry
* with a `sources` array listing every service it appears in.
* The best local_path is kept (prefer file:// from DEVICE over a cached
* Picker download, since device URIs are always fresh).
*/
const mergeMediaAcrossServices = (items) => {
// First pass: group by normalised filename
const byFilename = new Map();
const noFilename = [];
for (const item of items) {
const key = item.title?.toLowerCase().trim();
if (!key || key === 'untitled') {
noFilename.push({ ...item, sources: [item.source_service] });
continue;
}
if (byFilename.has(key)) {
const existing = byFilename.get(key);
// Add this service to the sources list
if (!existing.sources.includes(item.source_service)) {
existing.sources.push(item.source_service);
}
// Prefer a local device path over a cached download
if (item.source_service === 'DEVICE' && item.local_path) {
existing.local_path = item.local_path;
}
// Keep the best local_path if the existing one is missing
if (!existing.local_path && item.local_path) {
existing.local_path = item.local_path;
}
} else {
byFilename.set(key, { ...item, sources: [item.source_service] });
}
}
// Second pass: also deduplicate by source_id for items with different titles
const bySourceId = new Map();
for (const item of [...byFilename.values(), ...noFilename]) {
const key = item.source_id || item.id || item.local_path || Math.random().toString();
if (bySourceId.has(key)) {
const existing = bySourceId.get(key);
for (const src of (item.sources || [])) {
if (!existing.sources.includes(src)) existing.sources.push(src);
}
if (!existing.local_path && item.local_path) existing.local_path = item.local_path;
} else {
bySourceId.set(key, item);
}
}
return Array.from(bySourceId.values());
};
const HomeScreen = ({ navigation, route }) => { const HomeScreen = ({ navigation, route }) => {
const userId = route?.params?.userId; const userId = route?.params?.userId;
const { theme } = useTheme(); const { theme } = useTheme();
@@ -26,7 +85,10 @@ const HomeScreen = ({ navigation, route }) => {
const [viewerVisible, setViewerVisible] = useState(false); const [viewerVisible, setViewerVisible] = useState(false);
const [deviceEndCursor, setDeviceEndCursor] = useState(null); const [deviceEndCursor, setDeviceEndCursor] = useState(null);
const [deviceHasMore, setDeviceHasMore] = useState(false); const [deviceHasMore, setDeviceHasMore] = useState(false);
const [googleNextPage, setGoogleNextPage] = 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(() => {
@@ -56,7 +118,7 @@ const HomeScreen = ({ navigation, route }) => {
try { try {
const newMedia = []; const newMedia = [];
// ─── Device photos (camera roll + Google Photos synced images) ─── // ─── Device photos (local camera roll, screenshots, etc.) ───
try { try {
const deviceResult = await DeviceMediaService.getPhotos(100); const deviceResult = await DeviceMediaService.getPhotos(100);
if (deviceResult.mediaItems.length) { if (deviceResult.mediaItems.length) {
@@ -68,6 +130,30 @@ const HomeScreen = ({ navigation, route }) => {
console.error('Device media fetch error:', err); console.error('Device media fetch error:', err);
} }
// ─── Previously imported Google Photos (stored in DB) ───
try {
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
if (googleMedia?.length) {
// Validate cached files still exist — clear stale paths
const validated = googleMedia.map((item) => {
if (item.local_path) {
try {
const f = new File(item.local_path);
if (!f.exists || f.size === 0) {
return { ...item, local_path: null };
}
} catch (_) {
return { ...item, local_path: null };
}
}
return item;
});
newMedia.push(...validated);
}
} catch (err) {
console.error('DB Google media fetch error:', err);
}
// ─── NAS fetch ─── // ─── NAS fetch ───
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS'); const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
if (nasAccount) { if (nasAccount) {
@@ -89,10 +175,11 @@ const HomeScreen = ({ navigation, route }) => {
console.log('iCloud: placeholder — would fetch photos here'); console.log('iCloud: placeholder — would fetch photos here');
} }
const unique = Array.from( // ─── Merge & cross-reference across services ───
new Map(newMedia.map((m) => [m.source_id || m.id || m.local_path, m])).values() // Match items across sources by filename so we can show "this photo
); // exists on Device AND Google Photos" etc.
setAllMedia(unique); const merged = mergeMediaAcrossServices(newMedia);
setAllMedia(merged);
} catch (err) { } catch (err) {
console.error('Error loading media:', err); console.error('Error loading media:', err);
} finally { } finally {
@@ -101,30 +188,125 @@ 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 with cross-service matching
setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems]));
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') {
filtered = allMedia.filter((m) => m.source_service === selectedFilter); // Check both source_service and the sources array for cross-service items
filtered = allMedia.filter((m) =>
m.source_service === selectedFilter ||
(m.sources && m.sources.includes(selectedFilter))
);
} }
filtered.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0)); filtered.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0));
setFilteredMedia(filtered); setFilteredMedia(filtered);
}; };
const loadMorePhotos = async () => { const loadMorePhotos = async () => {
if (loadingMore || !deviceHasMore) return; if (loadingMore) return;
const canLoadDevice = deviceHasMore;
const canLoadGoogle = !!googleNextPage && !!googleSessionId;
if (!canLoadDevice && !canLoadGoogle) return;
setLoadingMore(true); setLoadingMore(true);
try { try {
const result = await DeviceMediaService.getPhotos(100, deviceEndCursor); const moreMedia = [];
if (result.mediaItems.length) {
setAllMedia((prev) => { if (canLoadDevice) {
const combined = [...prev, ...result.mediaItems]; const result = await DeviceMediaService.getPhotos(100, deviceEndCursor);
return Array.from( if (result.mediaItems.length) moreMedia.push(...result.mediaItems);
new Map(combined.map((m) => [m.source_id || m.id || m.local_path, m])).values() setDeviceEndCursor(result.endCursor);
); setDeviceHasMore(result.hasNextPage);
}); }
if (canLoadGoogle) {
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.getMorePhotos(100, googleNextPage, googleSessionId);
if (photos?.mediaItems?.length) {
moreMedia.push(...photos.mediaItems);
for (const item of photos.mediaItems) {
await DatabaseService.saveMediaItem(userId, item);
}
}
setGoogleNextPage(photos?.nextPageToken || null);
if (photos?.sessionId) setGoogleSessionId(photos.sessionId);
}
}
if (moreMedia.length) {
setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...moreMedia]));
} }
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 {
@@ -135,6 +317,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 }]}>
@@ -188,12 +372,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 }]}
@@ -241,9 +446,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' },
}); });
+202 -116
View File
@@ -1,11 +1,19 @@
// noinspection SqlNoDataSourceInspection,SqlResolve
/**
* Database service — all tables are created at runtime by expo-sqlite.
* The IDE cannot resolve them statically; the SQL warnings are safe to ignore.
*/
import * as SQLite from 'expo-sqlite'; import * as SQLite from 'expo-sqlite';
import CredentialEncryption from './CredentialEncryption'; import CredentialEncryption from './CredentialEncryption';
import { logError } from './ErrorHandler';
let db = null; let db = null;
let initPromise = null; let initPromise = null;
const DatabaseService = { const DatabaseService = {
// Open the database and create all tables // ─── Initialisation ────────────────────────────────────────
initDatabase: async () => { initDatabase: async () => {
if (initPromise) return initPromise; if (initPromise) return initPromise;
@@ -14,12 +22,17 @@ const DatabaseService = {
if (db) return true; if (db) return true;
if (typeof SQLite.openDatabaseAsync !== 'function') { if (typeof SQLite.openDatabaseAsync !== 'function') {
throw new Error('SQLite openDatabaseAsync API not available'); logError('DB Init', 'SQLite openDatabaseAsync API not available');
return false;
} }
db = await SQLite.openDatabaseAsync('PhotosApp.db'); db = await SQLite.openDatabaseAsync('PhotosApp.db');
if (!db) throw new Error('SQLite openDatabaseAsync returned null'); if (!db) {
logError('DB Init', 'SQLite openDatabaseAsync returned null');
return false;
}
// Create all tables in one batch
await db.execAsync(` await db.execAsync(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -27,10 +40,7 @@ const DatabaseService = {
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) );
`);
await db.execAsync(`
CREATE TABLE IF NOT EXISTS linked_accounts ( CREATE TABLE IF NOT EXISTS linked_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -40,10 +50,7 @@ const DatabaseService = {
is_default INTEGER DEFAULT 0, is_default INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
) );
`);
await db.execAsync(`
CREATE TABLE IF NOT EXISTS media ( CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -58,20 +65,20 @@ const DatabaseService = {
synced INTEGER DEFAULT 0, synced INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
) );
`); `);
// Add optional columns (safe migration — errors ignored if columns already exist) // Safe migrations — errors ignored if columns already exist
try { await db.execAsync(`ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'`); } catch (_) {} try { await db.execAsync("ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'"); } catch (_) {}
try { await db.execAsync(`ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'`); } catch (_) {} try { await db.execAsync("ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'"); } catch (_) {}
console.log('Database initialized successfully'); console.log('Database initialized successfully');
return true; return true;
} catch (error) { } catch (error) {
console.error('Database initialization error:', error); logError('DB Init', error);
db = null; db = null;
initPromise = null; initPromise = null;
throw error; return false;
} }
})(); })();
@@ -80,12 +87,18 @@ const DatabaseService = {
ensureDb: async () => { ensureDb: async () => {
await DatabaseService.initDatabase(); await DatabaseService.initDatabase();
if (!db) throw new Error('Database not initialized'); if (!db) {
logError('DB', 'Database not initialized');
return false;
}
return true;
}, },
// ─── Users ─────────────────────────────────────────────────
createUser: async (username, email, password) => { createUser: async (username, email, password) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
const hashedPassword = await CredentialEncryption.hashPassword(password); const hashedPassword = await CredentialEncryption.hashPassword(password);
const result = await db.runAsync( const result = await db.runAsync(
'INSERT INTO users (username, email, password) VALUES (?, ?, ?)', 'INSERT INTO users (username, email, password) VALUES (?, ?, ?)',
@@ -93,98 +106,111 @@ const DatabaseService = {
); );
return result.lastInsertRowId; return result.lastInsertRowId;
} catch (error) { } catch (error) {
console.error('Error creating user:', error); logError('Create User', error);
throw error; return null;
} }
}, },
// Authenticate by username or email
loginUser: async (identifier, password) => { loginUser: async (identifier, password) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
const row = await db.getFirstAsync( const row = await db.getFirstAsync(
'SELECT * FROM users WHERE username = ? OR email = ?', 'SELECT * FROM users WHERE username = ? OR email = ?',
[identifier, identifier] [identifier, identifier]
); );
if (!row) return null; if (!row) return null;
const valid = await CredentialEncryption.verifyPassword(password, row.password); const valid = await CredentialEncryption.verifyPassword(password, row.password);
if (!valid) return null; return valid ? row : null;
return row;
} catch (error) { } catch (error) {
console.error('Error logging in user:', error); logError('Login User', error);
throw error; return null;
} }
}, },
userExists: async (username, email) => { userExists: async (username, email) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return false;
const row = await db.getFirstAsync( const row = await db.getFirstAsync(
'SELECT id FROM users WHERE username = ? OR email = ?', 'SELECT id FROM users WHERE username = ? OR email = ?',
[username, email] [username, email]
); );
return !!row; return !!row;
} catch (error) { } catch (error) {
console.error('Error checking if user exists:', error); logError('User Exists', error);
throw error; return false;
} }
}, },
getUserById: async (userId) => { getUserById: async (userId) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
if (!userId && userId !== 0) throw new Error('User ID is required'); if (!userId && userId !== 0) {
logError('Get User', 'User ID is required');
return null;
}
return (await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId])) || null; return (await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId])) || null;
} catch (error) { } catch (error) {
console.error('Error getting user:', error); logError('Get User', error);
throw error; return null;
} }
}, },
// Link an external service account (Google, iCloud, NAS) to a local user // ─── Linked Accounts ──────────────────────────────────────
linkAccount: async (userId, serviceType, accountData, isDefault = false) => { linkAccount: async (userId, serviceType, accountData, isDefault = false) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
if (!userId) throw new Error('User ID is required'); if (!userId || !serviceType || !accountData || typeof accountData !== 'object') {
if (!serviceType) throw new Error('Service type is required'); logError('Link Account', 'userId, serviceType, and accountData object are required');
if (!accountData || typeof accountData !== 'object') throw new Error('Account data must be a valid object'); return null;
}
const encryptedData = CredentialEncryption.encrypt(accountData); const encryptedData = CredentialEncryption.encrypt(accountData);
if (!encryptedData) throw new Error('Encryption returned empty data'); if (!encryptedData) {
logError('Link Account', 'Encryption returned empty data');
return null;
}
// Replace any existing link for the same service type // Replace any existing link for the same service type
await db.runAsync('DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?', [userId, serviceType]); await db.runAsync(
'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?',
[userId, serviceType]
);
const result = await db.runAsync( const result = await db.runAsync(
'INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) VALUES (?, ?, ?, ?)', 'INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) VALUES (?, ?, ?, ?)',
[userId, serviceType, encryptedData, isDefault ? 1 : 0] [userId, serviceType, encryptedData, isDefault ? 1 : 0]
); );
return result.lastInsertRowId; return result.lastInsertRowId;
} catch (error) { } catch (error) {
console.error('Error linking account:', error); logError('Link Account', error);
throw new Error('Failed to link account: ' + error.message); return null;
} }
}, },
getLinkedAccounts: async (userId) => { getLinkedAccounts: async (userId) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return [];
if (!userId && userId !== 0) throw new Error('User ID is required'); if (!userId && userId !== 0) {
logError('Get Linked Accounts', 'User ID is required');
return [];
}
const rows = await db.getAllAsync( const rows = await db.getAllAsync(
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1', [userId] 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_enabled = 1',
[userId]
); );
return rows.map((row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) })); return rows.map((row) => ({
...row,
account_data: CredentialEncryption.decrypt(row.account_data),
}));
} catch (error) { } catch (error) {
console.error('Error getting linked accounts:', error); logError('Get Linked Accounts', error);
throw error; return [];
} }
}, },
getLinkedAccountByService: async (userId, serviceType) => { getLinkedAccountByService: async (userId, serviceType) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
const row = await db.getFirstAsync( const row = await db.getFirstAsync(
'SELECT * FROM linked_accounts WHERE user_id = ? AND service_type = ? AND is_enabled = 1', 'SELECT * FROM linked_accounts WHERE user_id = ? AND service_type = ? AND is_enabled = 1',
[userId, serviceType] [userId, serviceType]
@@ -192,40 +218,83 @@ const DatabaseService = {
if (!row) return null; if (!row) return null;
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) }; return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
} catch (error) { } catch (error) {
console.error('Error getting linked account:', error); logError('Get Linked Account By Service', error);
throw error; return null;
} }
}, },
setDefaultService: async (userId, accountId) => { setDefaultService: async (userId, accountId) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return;
await db.runAsync('UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?', [userId]); await db.runAsync('UPDATE linked_accounts SET is_default = 0 WHERE user_id = ?', [userId]);
await db.runAsync('UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?', [accountId, userId]); await db.runAsync('UPDATE linked_accounts SET is_default = 1 WHERE id = ? AND user_id = ?', [accountId, userId]);
} catch (error) { } catch (error) {
console.error('Error setting default service:', error); logError('Set Default Service', error);
throw error;
} }
}, },
getDefaultService: async (userId) => { getDefaultService: async (userId) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
const row = await db.getFirstAsync( const row = await db.getFirstAsync(
'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1', [userId] 'SELECT * FROM linked_accounts WHERE user_id = ? AND is_default = 1',
[userId]
); );
if (!row) return null; if (!row) return null;
return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) }; return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) };
} catch (error) { } catch (error) {
console.error('Error getting default service:', error); logError('Get Default Service', error);
throw error; return null;
} }
}, },
toggleAccountEnabled: async (accountId, enabled) => {
try {
if (!(await DatabaseService.ensureDb())) return;
await db.runAsync(
'UPDATE linked_accounts SET is_enabled = ? WHERE id = ?',
[enabled ? 1 : 0, accountId]
);
} catch (error) {
logError('Toggle Account', error);
}
},
removeLinkedAccount: async (accountId) => {
try {
if (!(await DatabaseService.ensureDb())) return;
await db.runAsync('DELETE FROM linked_accounts WHERE id = ?', [accountId]);
} catch (error) {
logError('Remove Account', error);
}
},
getAllLinkedAccounts: async (userId) => {
try {
if (!(await DatabaseService.ensureDb())) return [];
const rows = await db.getAllAsync(
'SELECT * FROM linked_accounts WHERE user_id = ?',
[userId]
);
return rows.map((row) => ({
...row,
account_data: CredentialEncryption.decrypt(row.account_data),
}));
} catch (error) {
logError('Get All Linked Accounts', error);
return [];
}
},
// ─── Media ─────────────────────────────────────────────────
addMedia: async (userId, sourceService, sourceId, title, description, localPath, remotePath, mediaType, createdDate) => { addMedia: async (userId, sourceService, sourceId, title, description, localPath, remotePath, mediaType, createdDate) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return null;
if (!userId || !sourceService) throw new Error('userId and sourceService are required'); if (!userId || !sourceService) {
logError('Add Media', 'userId and sourceService are required');
return null;
}
const result = await db.runAsync( const result = await db.runAsync(
`INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date) `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -234,109 +303,126 @@ const DatabaseService = {
); );
return result.lastInsertRowId; return result.lastInsertRowId;
} catch (error) { } catch (error) {
console.error('Error adding media:', error); logError('Add Media', error);
throw error; return null;
}
},
/**
* Save a normalised media item object to the DB.
* Skips duplicates (same user_id + source_id).
*/
saveMediaItem: async (userId, item) => {
try {
if (!(await DatabaseService.ensureDb())) return null;
if (!userId || !item?.source_service) return null;
// Skip if already exists
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) {
logError('Save Media Item', error);
return null;
} }
}, },
getMediaByUser: async (userId) => { getMediaByUser: async (userId) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return [];
if (!userId && userId !== 0) throw new Error('User ID is required'); if (!userId && userId !== 0) {
return await db.getAllAsync('SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', [userId]); logError('Get Media', 'User ID is required');
return [];
}
return await db.getAllAsync(
'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC',
[userId]
);
} catch (error) { } catch (error) {
console.error('Error getting media:', error); logError('Get Media', error);
throw error; return [];
} }
}, },
getMediaByService: async (userId, serviceType) => { getMediaByService: async (userId, serviceType) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return [];
return await db.getAllAsync( return await db.getAllAsync(
'SELECT * FROM media WHERE user_id = ? AND source_service = ? ORDER BY created_date DESC', 'SELECT * FROM media WHERE user_id = ? AND source_service = ? ORDER BY created_date DESC',
[userId, serviceType] [userId, serviceType]
); );
} catch (error) { } catch (error) {
console.error('Error getting media by service:', error); logError('Get Media By Service', error);
throw error; return [];
} }
}, },
searchMedia: async (userId, query) => { searchMedia: async (userId, query) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return [];
const searchTerm = `%${query}%`; const searchTerm = `%${query}%`;
return await db.getAllAsync( return await db.getAllAsync(
'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC', 'SELECT * FROM media WHERE user_id = ? AND (title LIKE ? OR description LIKE ?) ORDER BY created_date DESC',
[userId, searchTerm, searchTerm] [userId, searchTerm, searchTerm]
); );
} catch (error) { } catch (error) {
console.error('Error searching media:', error); logError('Search Media', error);
throw error; return [];
} }
}, },
// ─── Settings ──────────────────────────────────────────────
getAutoBackupServices: async (userId) => { getAutoBackupServices: async (userId) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return [];
const row = await db.getFirstAsync('SELECT auto_backup_services FROM users WHERE id = ?', [userId]); const row = await db.getFirstAsync(
'SELECT auto_backup_services FROM users WHERE id = ?',
[userId]
);
if (!row?.auto_backup_services) return []; if (!row?.auto_backup_services) return [];
return JSON.parse(row.auto_backup_services); return JSON.parse(row.auto_backup_services);
} catch (error) { } catch (error) {
console.error('Error getting auto backup services:', error); logError('Get Auto Backup', error);
return []; return [];
} }
}, },
setAutoBackupServices: async (userId, services) => { setAutoBackupServices: async (userId, services) => {
try { try {
await DatabaseService.ensureDb(); if (!(await DatabaseService.ensureDb())) return;
await db.runAsync('UPDATE users SET auto_backup_services = ? WHERE id = ?', [JSON.stringify(services), userId]); await db.runAsync(
'UPDATE users SET auto_backup_services = ? WHERE id = ?',
[JSON.stringify(services), userId]
);
} catch (error) { } catch (error) {
console.error('Error setting auto backup services:', error); logError('Set Auto Backup', error);
throw error;
} }
}, },
toggleAccountEnabled: async (accountId, enabled) => { // ─── Lifecycle ─────────────────────────────────────────────
try {
await DatabaseService.ensureDb();
await db.runAsync('UPDATE linked_accounts SET is_enabled = ? WHERE id = ?', [enabled ? 1 : 0, accountId]);
} catch (error) {
console.error('Error toggling account:', error);
throw error;
}
},
removeLinkedAccount: async (accountId) => {
try {
await DatabaseService.ensureDb();
await db.runAsync('DELETE FROM linked_accounts WHERE id = ?', [accountId]);
} catch (error) {
console.error('Error removing account:', error);
throw error;
}
},
getAllLinkedAccounts: async (userId) => {
try {
await DatabaseService.ensureDb();
const rows = await db.getAllAsync('SELECT * FROM linked_accounts WHERE user_id = ?', [userId]);
return rows.map((row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) }));
} catch (error) {
console.error('Error getting all linked accounts:', error);
throw error;
}
},
closeDatabase: async () => { closeDatabase: async () => {
try { try {
if (db?.closeAsync) await db.closeAsync(); if (db?.closeAsync) await db.closeAsync();
} catch (error) {
logError('Close DB', error);
} finally {
db = null; db = null;
initPromise = null; initPromise = null;
} catch (error) {
console.error('Error closing database:', error);
} }
}, },
}; };
+36
View File
@@ -0,0 +1,36 @@
import { Alert } from 'react-native';
/**
* Centralised error handler for the app.
*
* Usage:
* import { logError, alertError } from '../services/ErrorHandler';
*
* logError('Google Auth', error); // console.error only
* alertError('Upload Failed', error); // Alert + console.error
*/
/**
* Log an error to the console with a contextual tag.
* @param {string} tag – short label, e.g. 'Google Auth', 'NAS'
* @param {Error|string} error
*/
export const logError = (tag, error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[${tag}] ${message}`);
};
/**
* Show a user-facing Alert AND log to the console.
* @param {string} title – Alert title
* @param {Error|string} error
* @param {Array} [buttons] – optional Alert button array
*/
export const alertError = (title, error, buttons) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[${title}] ${message}`);
Alert.alert(title, message, buttons || [{ text: 'OK' }]);
};
export default { logError, alertError };