mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
11 files changed
+1027
-474
No files matched your search
+68
-43
@@ -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');
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|
||||||
Reference in new issue
Block a user