refactor: clean up comments and unused code in various files

This commit is contained in:
bobbert committed 2026-04-14 19:05:22 +01:00
1 parent 9b20804f5a
commit bb6bd0f4b3
28 files changed
+14 -392

No files matched your search

-6
View File
@@ -14,14 +14,10 @@ import HomeScreen from './src/screens/HomeScreen';
import SearchScreen from './src/screens/SearchScreen'; import SearchScreen from './src/screens/SearchScreen';
import CollectionsScreen from './src/screens/AlbumsScreen'; import CollectionsScreen from './src/screens/AlbumsScreen';
import SettingsScreen from './src/screens/SettingsScreen'; import SettingsScreen from './src/screens/SettingsScreen';
// Required for OAuth deep link redirect to close the browser and return to the app
WebBrowser.maybeCompleteAuthSession(); WebBrowser.maybeCompleteAuthSession();
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
const Tab = createBottomTabNavigator(); const Tab = createBottomTabNavigator();
// Bottom tab navigator shown after login
function MainTabs({ route }) { function MainTabs({ route }) {
const { userId } = route.params || {}; const { userId } = route.params || {};
const { theme } = useTheme(); const { theme } = useTheme();
@@ -73,8 +69,6 @@ function MainTabs({ route }) {
</Tab.Navigator> </Tab.Navigator>
); );
} }
// Root stack navigator
function AppNavigator() { function AppNavigator() {
return ( return (
<Stack.Navigator screenOptions={{ headerShown: false }}> <Stack.Navigator screenOptions={{ headerShown: false }}>
+2 -41
View File
@@ -14,17 +14,12 @@ const execFileAsync = util.promisify(execFile);
const SSH_OPTIONS = ['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null']; const SSH_OPTIONS = ['-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null'];
// Read the default NAS photo directory from the config file
const NAS_CONFIG_PATH = path.join(__dirname, 'nas_config.txt'); const NAS_CONFIG_PATH = path.join(__dirname, 'nas_config.txt');
let DEFAULT_PHOTO_DIR = '/home/user/photos'; let DEFAULT_PHOTO_DIR = '/home/user/photos';
try { try {
const raw = fs.readFileSync(NAS_CONFIG_PATH, 'utf8').trim(); const raw = fs.readFileSync(NAS_CONFIG_PATH, 'utf8').trim();
if (raw) DEFAULT_PHOTO_DIR = raw.split('\n')[0].trim(); if (raw) DEFAULT_PHOTO_DIR = raw.split('\n')[0].trim();
} catch (_) { } catch (_) {}
// Config file missing — keep the hard-coded default
}
// SSH/SFTP shell helpers
function runCommand(cmd, args, options = {}) { function runCommand(cmd, args, options = {}) {
return execFileAsync(cmd, args, { return execFileAsync(cmd, args, {
@@ -66,8 +61,6 @@ async function runSftpBatch(conn, batchCommands) {
} }
} }
// Express setup
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
@@ -82,8 +75,6 @@ app.use((req, res, next) => {
next(); next();
}); });
// JWT verification middleware
function verifyToken(req, res, next) { function verifyToken(req, res, next) {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
const token = authHeader?.split(' ')[1]; const token = authHeader?.split(' ')[1];
@@ -100,10 +91,6 @@ function verifyToken(req, res, next) {
} }
} }
// ─── REST Endpoints ──────────────────────────────────────────────
// Health check
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
res.json({ res.json({
status: 'ok', status: 'ok',
@@ -113,14 +100,10 @@ app.get('/api/health', (req, res) => {
}); });
}); });
// Return the current NAS photo directory read from nas_config.txt
app.get('/api/config/photo-dir', (req, res) => { app.get('/api/config/photo-dir', (req, res) => {
res.json({ photoDir: DEFAULT_PHOTO_DIR }); res.json({ photoDir: DEFAULT_PHOTO_DIR });
}); });
// Establish an SFTP connection (stores credentials, returns a JWT)
app.post('/api/sftp/connect', (req, res) => { app.post('/api/sftp/connect', (req, res) => {
const { host, port = 22, username, password } = req.body; const { host, port = 22, username, password } = req.body;
if (!host || !username || !password) { if (!host || !username || !password) {
@@ -141,8 +124,6 @@ app.post('/api/sftp/connect', (req, res) => {
}); });
}); });
// List directory contents
app.post('/api/sftp/list', verifyToken, async (req, res) => { app.post('/api/sftp/list', verifyToken, async (req, res) => {
const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body; const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body;
const conn = req.connection; const conn = req.connection;
@@ -170,8 +151,6 @@ app.post('/api/sftp/list', verifyToken, async (req, res) => {
} }
}); });
// Download a file from the NAS
app.get('/api/sftp/download', verifyToken, async (req, res) => { app.get('/api/sftp/download', verifyToken, async (req, res) => {
const { path: filePath } = req.query; const { path: filePath } = req.query;
if (!filePath) return res.status(400).json({ error: 'Missing path parameter' }); if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
@@ -183,7 +162,7 @@ app.get('/api/sftp/download', verifyToken, async (req, res) => {
try { try {
await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath)); await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath));
res.download(tempFilePath, filename, () => { res.download(tempFilePath, filename, () => {
try { fs.unlinkSync(tempFilePath); } catch (_) { /* already cleaned */ } try { fs.unlinkSync(tempFilePath); } catch (_) {}
}); });
} catch (err) { } catch (err) {
console.error('Download error:', err.message); console.error('Download error:', err.message);
@@ -191,8 +170,6 @@ app.get('/api/sftp/download', verifyToken, async (req, res) => {
} }
}); });
// Upload a file to the NAS
app.post('/api/sftp/upload', verifyToken, async (req, res) => { app.post('/api/sftp/upload', verifyToken, async (req, res) => {
const { remotePath } = req.body; const { remotePath } = req.body;
if (!remotePath || !req.files?.file) { if (!remotePath || !req.files?.file) {
@@ -218,8 +195,6 @@ app.post('/api/sftp/upload', verifyToken, async (req, res) => {
} }
}); });
// Create a directory on the NAS
app.post('/api/sftp/mkdir', verifyToken, async (req, res) => { app.post('/api/sftp/mkdir', verifyToken, async (req, res) => {
const { path: dirPath } = req.body; const { path: dirPath } = req.body;
if (!dirPath) return res.status(400).json({ error: 'Missing path parameter' }); if (!dirPath) return res.status(400).json({ error: 'Missing path parameter' });
@@ -234,8 +209,6 @@ app.post('/api/sftp/mkdir', verifyToken, async (req, res) => {
} }
}); });
// Delete a file on the NAS
app.post('/api/sftp/delete', verifyToken, async (req, res) => { app.post('/api/sftp/delete', verifyToken, async (req, res) => {
const { path: filePath } = req.body; const { path: filePath } = req.body;
if (!filePath) return res.status(400).json({ error: 'Missing path parameter' }); if (!filePath) return res.status(400).json({ error: 'Missing path parameter' });
@@ -250,18 +223,12 @@ app.post('/api/sftp/delete', verifyToken, async (req, res) => {
} }
}); });
// Disconnect from the NAS
app.post('/api/sftp/disconnect', verifyToken, (req, res) => { app.post('/api/sftp/disconnect', verifyToken, (req, res) => {
connections.delete(req.connectionId); connections.delete(req.connectionId);
console.log(`Disconnected: ${req.connectionId}`); console.log(`Disconnected: ${req.connectionId}`);
res.json({ success: true, message: 'Disconnected from SFTP server' }); res.json({ success: true, message: 'Disconnected from SFTP server' });
}); });
// ─── Google Photos → NAS Proxy Endpoints ─────────────────────────
// Fetch media items from Google Photos on behalf of the mobile client
app.post('/api/google-photos/fetch', verifyToken, async (req, res) => { app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
const { googleAccessToken, pageSize = 50, pageToken } = req.body; const { googleAccessToken, pageSize = 50, pageToken } = req.body;
if (!googleAccessToken) { if (!googleAccessToken) {
@@ -302,8 +269,6 @@ app.post('/api/google-photos/fetch', verifyToken, async (req, res) => {
} }
}); });
// Download a Google Photos item and save it directly to the NAS
app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) => { app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) => {
const { googleAccessToken, baseUrl, filename, remotePath } = req.body; const { googleAccessToken, baseUrl, filename, remotePath } = req.body;
const conn = req.connection; const conn = req.connection;
@@ -337,14 +302,10 @@ app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) =>
} }
}); });
// Sync status (placeholder for future batch sync)
app.post('/api/google-photos/sync-status', verifyToken, (req, res) => { app.post('/api/google-photos/sync-status', verifyToken, (req, res) => {
res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null }); res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null });
}); });
// ─── Lifecycle ───────────────────────────────────────────────────
process.on('SIGINT', () => { process.on('SIGINT', () => {
console.log('\nShutting down...'); console.log('\nShutting down...');
connections.forEach((_, id) => connections.delete(id)); connections.forEach((_, id) => connections.delete(id));
-22
View File
@@ -1,21 +1,3 @@
// Google Photos Picker API Test
//
// Usage:
// node TEST/testGooglePhotosAPI.js <ACCESS_TOKEN>
// 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):
// 1. Click the gear icon → check "Use your own OAuth credentials"
// 2. Enter your Client ID and Client Secret from Google Cloud Console
// 3. In "Input your own scopes" enter:
// https://www.googleapis.com/auth/photospicker.mediaitems.readonly
// 4. Click "Authorize APIs" and sign in with your test Google account
// 5. Click "Exchange authorization code for tokens"
// 6. Copy the Access token (starts with ya29.) and pass it here
const PICKER_BASE = 'https://photospicker.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';
@@ -37,7 +19,6 @@ async function main() {
console.log('\n--- Google Photos Picker API Test ---\n'); console.log('\n--- Google Photos Picker API Test ---\n');
// 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 });
@@ -51,7 +32,6 @@ async function main() {
console.log(` Failed: ${err.message}\n`); console.log(` Failed: ${err.message}\n`);
} }
// Test 2 — Create Picker session
console.log('[2] Creating Picker session...'); console.log('[2] Creating Picker session...');
let sessionId = null; let sessionId = null;
try { try {
@@ -68,7 +48,6 @@ async function main() {
console.log(` Session created: ${sessionId}`); console.log(` Session created: ${sessionId}`);
console.log(` mediaItemsSet: ${session.mediaItemsSet}\n`); console.log(` mediaItemsSet: ${session.mediaItemsSet}\n`);
// Wait for session to be ready
if (!session.mediaItemsSet) { if (!session.mediaItemsSet) {
console.log(' Waiting for session to become ready...'); console.log(' Waiting for session to become ready...');
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
@@ -87,7 +66,6 @@ async function main() {
console.log(` Failed: ${err.message}\n`); console.log(` Failed: ${err.message}\n`);
} }
// Test 3 — List media items from session
if (sessionId) { if (sessionId) {
console.log('[3] Fetching media items from session...'); console.log('[3] Fetching media items from session...');
try { try {
-25
View File
@@ -1,31 +1,9 @@
/**
* NAS Backend API Test
*
* Usage:
* node TEST/testNASAPI.js
*
* Environment variables:
* BACKEND_URL (default http://localhost:3001)
* NAS_HOST e.g. 192.168.1.100
* NAS_PORT default 22
* NAS_USER SSH username
* NAS_PASS SSH password
* NAS_FOLDER default /home/user/photos
*
* Tests:
* 1. Backend health check
* 2. SFTP connect
* 3. List directory
* 4. Disconnect
*/
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001'; const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
async function main() { async function main() {
console.log('\n--- NAS Backend API Test ---'); console.log('\n--- NAS Backend API Test ---');
console.log(`Backend: ${BACKEND_URL}\n`); console.log(`Backend: ${BACKEND_URL}\n`);
// 1 — Health check
console.log('[1] Health check...'); console.log('[1] Health check...');
try { try {
const res = await fetch(`${BACKEND_URL}/api/health`); const res = await fetch(`${BACKEND_URL}/api/health`);
@@ -50,7 +28,6 @@ async function main() {
return; return;
} }
// 2 — Connect
let connectionToken = null; let connectionToken = null;
console.log(`[2] SFTP connect to ${host}:${port}...`); console.log(`[2] SFTP connect to ${host}:${port}...`);
try { try {
@@ -74,7 +51,6 @@ async function main() {
Authorization: `Bearer ${connectionToken}`, Authorization: `Bearer ${connectionToken}`,
}; };
// 3 — List directory
console.log(`[3] List directory "${folder}"...`); console.log(`[3] List directory "${folder}"...`);
try { try {
const res = await fetch(`${BACKEND_URL}/api/sftp/list`, { const res = await fetch(`${BACKEND_URL}/api/sftp/list`, {
@@ -93,7 +69,6 @@ async function main() {
console.log(` Failed: ${err.message}\n`); console.log(` Failed: ${err.message}\n`);
} }
// 4 — Disconnect
console.log('[4] Disconnect...'); console.log('[4] Disconnect...');
try { try {
const res = await fetch(`${BACKEND_URL}/api/sftp/disconnect`, { const res = await fetch(`${BACKEND_URL}/api/sftp/disconnect`, {
+1
View File
@@ -1,5 +1,6 @@
{ {
"expo": { "expo": {
"entryPoint": "./App.js",
"name": "MASS Photo App", "name": "MASS Photo App",
"slug": "mass-expo", "slug": "mass-expo",
"owner": "bobthebob", "owner": "bobthebob",
-8
View File
@@ -1,8 +0,0 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "media-aggregation-and-sorting-system", "name": "media-aggregation-and-sorting-system",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "App.js",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo run:android", "android": "expo run:android",
-5
View File
@@ -1,5 +1,3 @@
// 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 PICKER_ROOT = 'https://photospicker.googleapis.com/v1';
@@ -29,13 +27,10 @@ const GooglePhotosAPI = (getAccessToken) => {
}; };
return { return {
// Create a Picker session — must be called before listing items
createSession: () => callFetch(`${PICKER_ROOT}/sessions`, 'POST', {}), createSession: () => callFetch(`${PICKER_ROOT}/sessions`, 'POST', {}),
// Poll session status
getSession: (sessionId) => callFetch(`${PICKER_ROOT}/sessions/${sessionId}`, 'GET'), getSession: (sessionId) => callFetch(`${PICKER_ROOT}/sessions/${sessionId}`, 'GET'),
// List media items from a ready session
listMediaItems: (sessionId, pageSize = 25, pageToken) => { listMediaItems: (sessionId, pageSize = 25, pageToken) => {
const params = new URLSearchParams({ sessionId, pageSize: String(pageSize) }); const params = new URLSearchParams({ sessionId, pageSize: String(pageSize) });
if (pageToken) params.append('pageToken', pageToken); if (pageToken) params.append('pageToken', pageToken);
-1
View File
@@ -1,4 +1,3 @@
// iCloud Photos API placeholder — Apple does not provide a public REST API for iCloud Photos
const iCloudPhotosAPI = () => ({ const iCloudPhotosAPI = () => ({
listMediaItems: async () => ({ isSuccess: false, message: 'iCloud API not available' }), listMediaItems: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
getMediaItem: async () => ({ isSuccess: false, message: 'iCloud API not available' }), getMediaItem: async () => ({ isSuccess: false, message: 'iCloud API not available' }),
+1 -4
View File
@@ -1,6 +1,5 @@
import axios from 'axios'; import axios from 'axios';
// HTTP client that communicates with the SFTP backend (ServerFiles/server.js)
const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001'; const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001';
class NASAPI { class NASAPI {
@@ -11,7 +10,6 @@ class NASAPI {
this.credentials = null; this.credentials = null;
} }
// Establish a connection via the backend; falls back to local-only config if the backend is unreachable
async connect(host, port, username, password, photoFolder = '/Photos') { async connect(host, port, username, password, photoFolder = '/Photos') {
if (!host || !username || !password) throw new Error('Host, username, and password are required'); if (!host || !username || !password) throw new Error('Host, username, and password are required');
@@ -50,7 +48,6 @@ class NASAPI {
return response.data; return response.data;
} }
// Filter directory listing for supported photo/video files
async getPhotos(folder = null) { async getPhotos(folder = null) {
const targetFolder = folder || this.credentials?.photoFolder || '/Photos'; const targetFolder = folder || this.credentials?.photoFolder || '/Photos';
if (!this.connectionToken) return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' }; if (!this.connectionToken) return { mediaItems: [], folder: targetFolder, message: 'Backend not connected' };
@@ -120,7 +117,7 @@ class NASAPI {
if (this.connectionToken) { if (this.connectionToken) {
try { try {
await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 }); await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 });
} catch (_) { /* ignore */ } } catch (_) {}
} }
this.connectionToken = null; this.connectionToken = null;
this.isConnected = false; this.isConnected = false;
-1
View File
@@ -1,4 +1,3 @@
// Abstract base class for authentication services — provides a common interface for all providers
export class AuthServiceBase { export class AuthServiceBase {
constructor() { constructor() {
if (new.target === AuthServiceBase) { if (new.target === AuthServiceBase) {
+3 -75
View File
@@ -7,11 +7,8 @@ import { logError } from '../services/ErrorHandler';
WebBrowser.maybeCompleteAuthSession(); WebBrowser.maybeCompleteAuthSession();
// Android client ID — created in Google Cloud Console as "Android" type
// with package name com.bobthebob.massphotoapp and the SHA-1 from
// `eas credentials --platform android`.
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 IOS_CLIENT_ID = '313090284964-luka809nuc66bb4cg4gameovtqg8uoj1.apps.googleusercontent.com';
const GOOGLE_DISCOVERY = { const GOOGLE_DISCOVERY = {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
@@ -19,9 +16,6 @@ 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',
@@ -44,8 +38,6 @@ class GoogleAUTH extends AuthServiceBase {
this.email = null; this.email = null;
} }
// ───────────────────── Authentication ─────────────────────
async authenticate() { async authenticate() {
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');
@@ -188,23 +180,6 @@ 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' };
} }
// ───────────────── Photos Picker API ──────────────────
//
// 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
/**
* Create a new Picker session.
* Returns the full session object { id, pickerUri, mediaItemsSet, ... }
* or { error: { code, message } } on failure.
*/
async _createPickerSession() { async _createPickerSession() {
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const res = await fetch(`${PICKER_API}/sessions`, { const res = await fetch(`${PICKER_API}/sessions`, {
@@ -225,10 +200,6 @@ class GoogleAUTH extends AuthServiceBase {
return await res.json(); return await res.json();
} }
/**
* Poll a session until mediaItemsSet becomes true.
* Waits up to ~30 seconds (user needs time to select photos).
*/
async _waitForSession(sessionId) { async _waitForSession(sessionId) {
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const maxAttempts = 20; const maxAttempts = 20;
@@ -254,9 +225,6 @@ class GoogleAUTH extends AuthServiceBase {
return null; return null;
} }
/**
* Fetch media items from a ready Picker session.
*/
async _fetchPickerMediaItems(sessionId, pageSize = 100, pageToken = null) { async _fetchPickerMediaItems(sessionId, pageSize = 100, pageToken = null) {
const token = await this.getAccessToken(); const token = await this.getAccessToken();
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -277,7 +245,6 @@ class GoogleAUTH extends AuthServiceBase {
const data = await res.json(); const data = await res.json();
// Debug: log raw response structure
if (data.mediaItems?.length) { if (data.mediaItems?.length) {
console.log('Picker raw item[0]:', JSON.stringify(data.mediaItems[0], null, 2)); console.log('Picker raw item[0]:', JSON.stringify(data.mediaItems[0], null, 2));
} else { } else {
@@ -287,19 +254,10 @@ class GoogleAUTH extends AuthServiceBase {
return data; return data;
} }
/**
* Normalise Picker API media items to the app's standard format.
*
* The Picker API response shape per item:
* { id, mediaFile: { baseUrl, mimeType, filename, mediaFileMetadata: { ... } } }
*
* baseUrl requires an Authorization header to fetch the actual image bytes.
*/
_normalisePickerItems(items) { _normalisePickerItems(items) {
if (!items?.length) return []; if (!items?.length) return [];
return items.map((item, idx) => { return items.map((item, idx) => {
// The item may have mediaFile nested, or may be flat
const file = item.mediaFile || item; const file = item.mediaFile || item;
const baseUrl = file.baseUrl || item.baseUrl || null; const baseUrl = file.baseUrl || item.baseUrl || null;
const filename = file.filename || item.filename || 'Untitled'; const filename = file.filename || item.filename || 'Untitled';
@@ -328,24 +286,12 @@ class GoogleAUTH extends AuthServiceBase {
}); });
} }
/**
* 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) { async downloadPickerPhotos(items) {
const token = await this.getAccessToken(); const token = await this.getAccessToken();
// Ensure cache directory exists
if (!CACHE_DIR.exists) { if (!CACHE_DIR.exists) {
CACHE_DIR.create({ intermediates: true }); CACHE_DIR.create({ intermediates: true });
} else { } else {
// Clean up stale files from previous failed downloads (< 1KB = likely error pages)
try { try {
const entries = CACHE_DIR.list(); const entries = CACHE_DIR.list();
for (const entry of entries) { for (const entry of entries) {
@@ -353,7 +299,7 @@ class GoogleAUTH extends AuthServiceBase {
entry.delete(); entry.delete();
} }
} }
} catch (_) { /* ignore cleanup errors */ } } catch (_) {}
} }
const results = []; const results = [];
@@ -371,14 +317,11 @@ class GoogleAUTH extends AuthServiceBase {
: '.jpg'; : '.jpg';
const destFile = new File(CACHE_DIR, `${safeId}${ext}`); const destFile = new File(CACHE_DIR, `${safeId}${ext}`);
// Skip download if already cached and non-empty
if (destFile.exists && destFile.size > 1024) { if (destFile.exists && destFile.size > 1024) {
results.push({ ...item, local_path: destFile.uri }); results.push({ ...item, local_path: destFile.uri });
continue; 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)}…)`); console.log(`Downloading: ${item.title} (${item.picker_base_url.substring(0, 80)}…)`);
const downloaded = await File.downloadFileAsync( const downloaded = await File.downloadFileAsync(
@@ -392,19 +335,17 @@ class GoogleAUTH extends AuthServiceBase {
if (downloaded.exists && downloaded.size > 1024) { if (downloaded.exists && downloaded.size > 1024) {
results.push({ ...item, local_path: downloaded.uri }); results.push({ ...item, local_path: downloaded.uri });
} else if (downloaded.exists && downloaded.size > 0) { } else if (downloaded.exists && downloaded.size > 0) {
// Suspiciously small — check if it's an error page
try { try {
const peek = await downloaded.text(); const peek = await downloaded.text();
if (peek.startsWith('<') || peek.startsWith('{')) { if (peek.startsWith('<') || peek.startsWith('{')) {
logError('Download Picker Photo', logError('Download Picker Photo',
`Got error page for ${item.title}: ${peek.substring(0, 150)}`); `Got error page for ${item.title}: ${peek.substring(0, 150)}`);
downloaded.delete(); downloaded.delete();
results.push(item); // no local_path results.push(item);
} else { } else {
results.push({ ...item, local_path: downloaded.uri }); results.push({ ...item, local_path: downloaded.uri });
} }
} catch (_) { } catch (_) {
// Can't read as text = probably binary = probably an image
results.push({ ...item, local_path: downloaded.uri }); results.push({ ...item, local_path: downloaded.uri });
} }
} else { } else {
@@ -421,15 +362,8 @@ class GoogleAUTH extends AuthServiceBase {
return results; 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() { async pickPhotos() {
try { try {
// 1. Create session
console.log('Creating Google Photos Picker session…'); console.log('Creating Google Photos Picker session…');
const session = await this._createPickerSession(); const session = await this._createPickerSession();
@@ -449,7 +383,6 @@ class GoogleAUTH extends AuthServiceBase {
console.log('Picker session created:', session.id); console.log('Picker session created:', session.id);
console.log('Opening picker for user selection…'); 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, { await WebBrowser.openBrowserAsync(session.pickerUri, {
dismissButtonStyle: 'done', dismissButtonStyle: 'done',
presentationStyle: 'pageSheet', presentationStyle: 'pageSheet',
@@ -457,7 +390,6 @@ class GoogleAUTH extends AuthServiceBase {
console.log('Browser closed — polling for selected photos…'); console.log('Browser closed — polling for selected photos…');
// 3. Poll until the user's selections are ready
const ready = await this._waitForSession(session.id); const ready = await this._waitForSession(session.id);
if (!ready) { if (!ready) {
return { return {
@@ -466,7 +398,6 @@ class GoogleAUTH extends AuthServiceBase {
}; };
} }
// 4. Fetch selected items and download to local cache
const result = await this._fetchPickerMediaItems(session.id, 100, null); const result = await this._fetchPickerMediaItems(session.id, 100, null);
let mediaItems = this._normalisePickerItems(result.mediaItems); let mediaItems = this._normalisePickerItems(result.mediaItems);
@@ -485,9 +416,6 @@ class GoogleAUTH extends AuthServiceBase {
} }
} }
/**
* Fetch more items from an existing Picker session (pagination).
*/
async getMorePhotos(pageSize = 100, pageToken, sessionId) { async getMorePhotos(pageSize = 100, pageToken, sessionId) {
try { try {
if (!sessionId || !pageToken) { if (!sessionId || !pageToken) {
-1
View File
@@ -1,6 +1,5 @@
import { AuthServiceBase } from './AuthServiceBase'; import { AuthServiceBase } from './AuthServiceBase';
// iCloud Photos placeholder — Apple does not provide a public iCloud Photos API
class IOSAUTH extends AuthServiceBase { class IOSAUTH extends AuthServiceBase {
constructor() { constructor() {
super(); super();
-1
View File
@@ -1,7 +1,6 @@
import { AuthServiceBase } from './AuthServiceBase'; import { AuthServiceBase } from './AuthServiceBase';
import NASAPI from '../API/NASAPI'; import NASAPI from '../API/NASAPI';
// NAS SFTP authentication — connects to the backend server via NASAPI
class NASAUTH extends AuthServiceBase { class NASAUTH extends AuthServiceBase {
constructor() { constructor() {
super(); super();
+2 -22
View File
@@ -13,16 +13,6 @@ const getServiceIcon = (type) =>
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); ({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) => const getServiceName = (type) =>
({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type); ({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
/**
* Fullscreen media viewer with thumbnail carousel.
*
* Props:
* visible – boolean to show/hide the modal
* media – currently selected media item
* allMedia – (optional) full array of media items for carousel navigation
* onClose – callback when user dismisses the viewer
*/
const MediaViewer = ({ visible, media, allMedia, onClose }) => { const MediaViewer = ({ visible, media, allMedia, onClose }) => {
const { theme } = useTheme(); const { theme } = useTheme();
const mainListRef = useRef(null); const mainListRef = useRef(null);
@@ -33,7 +23,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []); const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []);
const hasCarousel = mediaList.length > 1; const hasCarousel = mediaList.length > 1;
// When the modal opens or media prop changes, scroll to the correct item
useEffect(() => { useEffect(() => {
if (!visible || !media || mediaList.length === 0) return; if (!visible || !media || mediaList.length === 0) return;
@@ -44,7 +33,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
setActiveIndex(newIndex); setActiveIndex(newIndex);
setCurrentMedia(mediaList[newIndex]); setCurrentMedia(mediaList[newIndex]);
// Delay scroll so FlatList has rendered
setTimeout(() => { setTimeout(() => {
if (mainListRef.current && newIndex >= 0) { if (mainListRef.current && newIndex >= 0) {
mainListRef.current.scrollToOffset({ offset: newIndex * SCREEN_WIDTH, animated: false }); mainListRef.current.scrollToOffset({ offset: newIndex * SCREEN_WIDTH, animated: false });
@@ -52,7 +40,7 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
if (thumbListRef.current && newIndex >= 0) { if (thumbListRef.current && newIndex >= 0) {
try { try {
thumbListRef.current.scrollToIndex({ index: newIndex, animated: false, viewPosition: 0.5 }); thumbListRef.current.scrollToIndex({ index: newIndex, animated: false, viewPosition: 0.5 });
} catch (e) { /* index might be out of range during initial render */ } } catch (e) {}
} }
}, 100); }, 100);
}, [visible, media]); }, [visible, media]);
@@ -69,7 +57,7 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
if (thumbListRef.current) { if (thumbListRef.current) {
try { try {
thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 }); thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
} catch (_) { /* ignore */ } } catch (_) {}
} }
} }
}; };
@@ -120,12 +108,10 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
return ( return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}> <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.container, { backgroundColor: theme.modalBg }]}> <View style={[styles.container, { backgroundColor: theme.modalBg }]}>
{/* Close button */}
<TouchableOpacity style={styles.closeButton} onPress={onClose}> <TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeText}>✕</Text> <Text style={styles.closeText}>✕</Text>
</TouchableOpacity> </TouchableOpacity>
{/* Counter */}
{hasCarousel && ( {hasCarousel && (
<View style={styles.counter}> <View style={styles.counter}>
<Text style={styles.counterText}> <Text style={styles.counterText}>
@@ -134,7 +120,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
</View> </View>
)} )}
{/* Main image area – swipeable */}
{hasCarousel ? ( {hasCarousel ? (
<FlatList <FlatList
ref={mainListRef} ref={mainListRef}
@@ -153,7 +138,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
style={styles.mainList} style={styles.mainList}
/> />
) : ( ) : (
// Single image mode (backward compat)
displayMedia.local_path ? ( displayMedia.local_path ? (
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" /> <Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
) : ( ) : (
@@ -163,7 +147,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
) )
)} )}
{/* Thumbnail carousel */}
{hasCarousel && ( {hasCarousel && (
<View style={styles.thumbCarousel}> <View style={styles.thumbCarousel}>
<FlatList <FlatList
@@ -183,7 +166,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
</View> </View>
)} )}
{/* Info panel */}
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}> <View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text> <Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
<View style={styles.row}> <View style={styles.row}>
@@ -234,7 +216,6 @@ const styles = StyleSheet.create({
mainList: { flex: 1 }, mainList: { flex: 1 },
fullImage: { flex: 1, width: '100%' }, fullImage: { flex: 1, width: '100%' },
fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' }, fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' },
// Thumbnail carousel
thumbCarousel: { thumbCarousel: {
position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15, position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15,
backgroundColor: 'rgba(0,0,0,0.4)', backgroundColor: 'rgba(0,0,0,0.4)',
@@ -250,7 +231,6 @@ const styles = StyleSheet.create({
width: '100%', height: '100%', backgroundColor: '#333', width: '100%', height: '100%', backgroundColor: '#333',
justifyContent: 'center', alignItems: 'center', justifyContent: 'center', alignItems: 'center',
}, },
// Info panel
infoPanel: { infoPanel: {
padding: 16, paddingBottom: 32, padding: 16, paddingBottom: 32,
borderTopLeftRadius: 16, borderTopRightRadius: 16, borderTopLeftRadius: 16, borderTopRightRadius: 16,
-2
View File
@@ -29,7 +29,6 @@ const PhotoGrid = ({
: [item.source_service]; : [item.source_service];
if (sources.length === 1) { if (sources.length === 1) {
// Single source — simple badge
return ( return (
<View style={styles.sourceBadge}> <View style={styles.sourceBadge}>
<Text style={{ fontSize: 10 }}>{getServiceIcon(sources[0])}</Text> <Text style={{ fontSize: 10 }}>{getServiceIcon(sources[0])}</Text>
@@ -37,7 +36,6 @@ const PhotoGrid = ({
); );
} }
// Multiple sources — show all icons with a subtle indicator
return ( return (
<View style={styles.multiSourceBadge}> <View style={styles.multiSourceBadge}>
{sources.map((src, i) => ( {sources.map((src, i) => (
-1
View File
@@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet } from 'react-native'; import { View, StyleSheet } from 'react-native';
// Circular health indicator: inactive=grey, healthy=green, error=red, checking=amber
const COLORS = { const COLORS = {
inactive: '#9e9e9e', inactive: '#9e9e9e',
healthy: '#34a853', healthy: '#34a853',
-7
View File
@@ -39,7 +39,6 @@ const CollectionsScreen = ({ route }) => {
const allMedia = await DatabaseService.getMediaByUser(userId); const allMedia = await DatabaseService.getMediaByUser(userId);
const sectionList = []; const sectionList = [];
// ─── Utility albums (top) ───
const utilityAlbums = [ const utilityAlbums = [
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' }, { name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' },
{ name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' }, { name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' },
@@ -48,7 +47,6 @@ const CollectionsScreen = ({ route }) => {
]; ];
sectionList.push({ title: '', data: [{ albums: utilityAlbums }] }); sectionList.push({ title: '', data: [{ albums: utilityAlbums }] });
// ─── Per-service categories ───
for (const acc of accounts) { for (const acc of accounts) {
const media = await DatabaseService.getMediaByService(userId, acc.service_type); const media = await DatabaseService.getMediaByService(userId, acc.service_type);
const serviceAlbums = [ const serviceAlbums = [
@@ -77,7 +75,6 @@ const CollectionsScreen = ({ route }) => {
} }
}; };
// Album detail view
if (selectedAlbum) { if (selectedAlbum) {
const gridItemSize = (width - 6) / 3; const gridItemSize = (width - 6) / 3;
return ( return (
@@ -209,13 +206,11 @@ const styles = StyleSheet.create({
header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 }, header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 },
headerTitle: { fontSize: 24, fontWeight: '700' }, headerTitle: { fontSize: 24, fontWeight: '700' },
listContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 }, listContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 },
// Section headers
sectionHeader: { sectionHeader: {
flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 8, flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 8,
}, },
sectionIcon: { fontSize: 20 }, sectionIcon: { fontSize: 20 },
sectionTitle: { fontSize: 16, fontWeight: '700' }, sectionTitle: { fontSize: 16, fontWeight: '700' },
// Album grid
albumRow: { flexDirection: 'row', gap: ALBUM_GAP, marginBottom: ALBUM_GAP }, albumRow: { flexDirection: 'row', gap: ALBUM_GAP, marginBottom: ALBUM_GAP },
albumCard: {}, albumCard: {},
albumCover: { borderRadius: 12, overflow: 'hidden', marginBottom: 8 }, albumCover: { borderRadius: 12, overflow: 'hidden', marginBottom: 8 },
@@ -225,7 +220,6 @@ const styles = StyleSheet.create({
}, },
albumName: { fontSize: 14, fontWeight: '600', paddingHorizontal: 2 }, albumName: { fontSize: 14, fontWeight: '600', paddingHorizontal: 2 },
albumCount: { fontSize: 12, paddingHorizontal: 2, marginTop: 2 }, albumCount: { fontSize: 12, paddingHorizontal: 2, marginTop: 2 },
// Album detail
albumHeader: { albumHeader: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16,
paddingTop: 52, paddingBottom: 12, gap: 12, borderBottomWidth: 1, paddingTop: 52, paddingBottom: 12, gap: 12, borderBottomWidth: 1,
@@ -238,7 +232,6 @@ const styles = StyleSheet.create({
gridItem: { margin: 1 }, gridItem: { margin: 1 },
gridImage: { width: '100%', height: '100%' }, gridImage: { width: '100%', height: '100%' },
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' }, gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
// Empty / Loading
loadingText: { marginTop: 12, fontSize: 14 }, loadingText: { marginTop: 12, fontSize: 14 },
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 }, emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center' }, emptyText: { fontSize: 13, textAlign: 'center' },
-33
View File
@@ -14,16 +14,7 @@ import PhotoGrid from '../components/PhotoGrid';
import MediaViewer from '../components/MediaViewer'; import MediaViewer from '../components/MediaViewer';
import { logError } from '../services/ErrorHandler'; 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) => { const mergeMediaAcrossServices = (items) => {
// First pass: group by normalised filename
const byFilename = new Map(); const byFilename = new Map();
const noFilename = []; const noFilename = [];
@@ -36,15 +27,12 @@ const mergeMediaAcrossServices = (items) => {
if (byFilename.has(key)) { if (byFilename.has(key)) {
const existing = byFilename.get(key); const existing = byFilename.get(key);
// Add this service to the sources list
if (!existing.sources.includes(item.source_service)) { if (!existing.sources.includes(item.source_service)) {
existing.sources.push(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) { if (item.source_service === 'DEVICE' && item.local_path) {
existing.local_path = 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) { if (!existing.local_path && item.local_path) {
existing.local_path = item.local_path; existing.local_path = item.local_path;
} }
@@ -52,8 +40,6 @@ const mergeMediaAcrossServices = (items) => {
byFilename.set(key, { ...item, sources: [item.source_service] }); byFilename.set(key, { ...item, sources: [item.source_service] });
} }
} }
// Second pass: also deduplicate by source_id for items with different titles
const bySourceId = new Map(); const bySourceId = new Map();
for (const item of [...byFilename.values(), ...noFilename]) { for (const item of [...byFilename.values(), ...noFilename]) {
const key = item.source_id || item.id || item.local_path || Math.random().toString(); const key = item.source_id || item.id || item.local_path || Math.random().toString();
@@ -118,7 +104,6 @@ const HomeScreen = ({ navigation, route }) => {
try { try {
const newMedia = []; const newMedia = [];
// ─── 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) {
@@ -130,11 +115,9 @@ 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 { try {
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS'); const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
if (googleMedia?.length) { if (googleMedia?.length) {
// Validate cached files still exist — clear stale paths
const validated = googleMedia.map((item) => { const validated = googleMedia.map((item) => {
if (item.local_path) { if (item.local_path) {
try { try {
@@ -154,7 +137,6 @@ const HomeScreen = ({ navigation, route }) => {
console.error('DB Google media fetch error:', err); console.error('DB Google media fetch error:', err);
} }
// ─── NAS fetch ───
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS'); const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
if (nasAccount) { if (nasAccount) {
try { try {
@@ -169,15 +151,11 @@ const HomeScreen = ({ navigation, route }) => {
} }
} }
// ─── iCloud (placeholder) ───
const iosAccount = linkedAccounts.find((a) => a.service_type === 'ICLOUD_PHOTOS'); const iosAccount = linkedAccounts.find((a) => a.service_type === 'ICLOUD_PHOTOS');
if (iosAccount) { if (iosAccount) {
console.log('iCloud: placeholder — would fetch photos here'); console.log('iCloud: placeholder — would fetch photos here');
} }
// ─── Merge & cross-reference across services ───
// Match items across sources by filename so we can show "this photo
// exists on Device AND Google Photos" etc.
const merged = mergeMediaAcrossServices(newMedia); const merged = mergeMediaAcrossServices(newMedia);
setAllMedia(merged); setAllMedia(merged);
} catch (err) { } catch (err) {
@@ -188,7 +166,6 @@ const HomeScreen = ({ navigation, route }) => {
} }
}; };
// ─── Import from Google Photos (opens picker in browser) ───
const importGooglePhotos = async () => { const importGooglePhotos = async () => {
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS'); const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
if (!googleAccount) { if (!googleAccount) {
@@ -206,7 +183,6 @@ const HomeScreen = ({ navigation, route }) => {
let result = await googleAuth.pickPhotos(); let result = await googleAuth.pickPhotos();
// If 403 (stale scopes), auto re-auth and retry
if (result?.error?.code === 403) { if (result?.error?.code === 403) {
console.log('Stale token — re-authenticating…'); console.log('Stale token — re-authenticating…');
await googleAuth.revokeAccess(); await googleAuth.revokeAccess();
@@ -223,7 +199,6 @@ const HomeScreen = ({ navigation, route }) => {
if (result?.error) { if (result?.error) {
logError('Import Google Photos', result.error.message); logError('Import Google Photos', result.error.message);
} else if (result?.mediaItems?.length) { } else if (result?.mediaItems?.length) {
// Save imported items to DB so they persist across app restarts
for (const item of result.mediaItems) { for (const item of result.mediaItems) {
await DatabaseService.saveMediaItem(userId, item); await DatabaseService.saveMediaItem(userId, item);
} }
@@ -231,13 +206,11 @@ const HomeScreen = ({ navigation, route }) => {
setGoogleNextPage(result.nextPageToken || null); setGoogleNextPage(result.nextPageToken || null);
setGoogleSessionId(result.sessionId || null); setGoogleSessionId(result.sessionId || null);
// Merge into current view with cross-service matching
setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems])); setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems]));
console.log(`Imported ${result.mediaItems.length} photos from Google`); console.log(`Imported ${result.mediaItems.length} photos from Google`);
} }
// Persist refreshed tokens
if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) { if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) {
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', { await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
accessToken: googleAuth.accessToken, accessToken: googleAuth.accessToken,
@@ -256,7 +229,6 @@ const HomeScreen = ({ navigation, route }) => {
const filterMedia = () => { const filterMedia = () => {
let filtered = allMedia; let filtered = allMedia;
if (selectedFilter !== 'all') { if (selectedFilter !== 'all') {
// Check both source_service and the sources array for cross-service items
filtered = allMedia.filter((m) => filtered = allMedia.filter((m) =>
m.source_service === selectedFilter || m.source_service === selectedFilter ||
(m.sources && m.sources.includes(selectedFilter)) (m.sources && m.sources.includes(selectedFilter))
@@ -328,7 +300,6 @@ const HomeScreen = ({ navigation, route }) => {
); );
} }
// Build the list of available source filters
const sourceFilters = [ const sourceFilters = [
{ key: 'DEVICE', icon: '📱', name: 'On Device' }, { key: 'DEVICE', icon: '📱', name: 'On Device' },
...linkedAccounts.map((acc) => ({ ...linkedAccounts.map((acc) => ({
@@ -372,7 +343,6 @@ const HomeScreen = ({ navigation, route }) => {
<FilterChips /> <FilterChips />
{/* Import from Google Photos button */}
{hasGoogleAccount && ( {hasGoogleAccount && (
<TouchableOpacity <TouchableOpacity
style={[styles.importButton, { backgroundColor: theme.primary }]} style={[styles.importButton, { backgroundColor: theme.primary }]}
@@ -437,7 +407,6 @@ const styles = StyleSheet.create({
container: { flex: 1 }, container: { flex: 1 },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 }, centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
loadingText: { marginTop: 12, fontSize: 14 }, loadingText: { marginTop: 12, fontSize: 14 },
// Filter chips
filterContainer: { maxHeight: 40 }, filterContainer: { maxHeight: 40 },
filterRow: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' }, filterRow: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' },
chip: { chip: {
@@ -446,7 +415,6 @@ 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: { importButton: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16, marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16,
@@ -454,7 +422,6 @@ const styles = StyleSheet.create({
}, },
importIcon: { fontSize: 16 }, importIcon: { fontSize: 16 },
importText: { color: '#fff', fontSize: 14, fontWeight: '600' }, importText: { color: '#fff', fontSize: 14, fontWeight: '600' },
// Empty
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 }, emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 }, emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 },
retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 }, retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
-2
View File
@@ -84,7 +84,6 @@ const LoginScreen = ({ navigation }) => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Login button – blue */}
<TouchableOpacity <TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]} style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]}
onPress={handleLogin} onPress={handleLogin}
@@ -97,7 +96,6 @@ const LoginScreen = ({ navigation }) => {
)} )}
</TouchableOpacity> </TouchableOpacity>
{/* Register button – white/outlined */}
<TouchableOpacity <TouchableOpacity
style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]} style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]}
onPress={() => navigation.navigate('Register')} onPress={() => navigation.navigate('Register')}
-15
View File
@@ -16,29 +16,24 @@ const OnboardingScreen = ({ navigation, route }) => {
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Service toggles
const [googleEnabled, setGoogleEnabled] = useState(false); const [googleEnabled, setGoogleEnabled] = useState(false);
const [iosEnabled, setIosEnabled] = useState(false); const [iosEnabled, setIosEnabled] = useState(false);
const [nasEnabled, setNasEnabled] = useState(false); const [nasEnabled, setNasEnabled] = useState(false);
// Service connection status
const [googleConnected, setGoogleConnected] = useState(false); const [googleConnected, setGoogleConnected] = useState(false);
const [iosConnected, setIosConnected] = useState(false); const [iosConnected, setIosConnected] = useState(false);
const [nasConnected, setNasConnected] = useState(false); const [nasConnected, setNasConnected] = useState(false);
// Service health: 'inactive' | 'healthy' | 'error' | 'checking'
const [googleHealth, setGoogleHealth] = useState('inactive'); const [googleHealth, setGoogleHealth] = useState('inactive');
const [iosHealth, setIosHealth] = useState('inactive'); const [iosHealth, setIosHealth] = useState('inactive');
const [nasHealth, setNasHealth] = useState('inactive'); const [nasHealth, setNasHealth] = useState('inactive');
// NAS config
const [nasHost, setNasHost] = useState(''); const [nasHost, setNasHost] = useState('');
const [nasPort, setNasPort] = useState('22'); const [nasPort, setNasPort] = useState('22');
const [nasUsername, setNasUsername] = useState(''); const [nasUsername, setNasUsername] = useState('');
const [nasPassword, setNasPassword] = useState(''); const [nasPassword, setNasPassword] = useState('');
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos'); const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
// Auto-backup checklist (multi-select)
const [autoBackupServices, setAutoBackupServices] = useState([]); const [autoBackupServices, setAutoBackupServices] = useState([]);
const steps = [ const steps = [
@@ -165,7 +160,6 @@ const OnboardingScreen = ({ navigation, route }) => {
return ( return (
<View style={[styles.container, { backgroundColor: theme.background }]}> <View style={[styles.container, { backgroundColor: theme.background }]}>
{/* Progress bar */}
<View style={styles.progressBar}> <View style={styles.progressBar}>
{steps.map((_, i) => ( {steps.map((_, i) => (
<View key={i} style={[styles.progressDot, { backgroundColor: i <= currentStep ? theme.primary : theme.border }]} /> <View key={i} style={[styles.progressDot, { backgroundColor: i <= currentStep ? theme.primary : theme.border }]} />
@@ -179,7 +173,6 @@ const OnboardingScreen = ({ navigation, route }) => {
<Text style={[styles.description, { color: theme.textSecondary }]}>{steps[currentStep].description}</Text> <Text style={[styles.description, { color: theme.textSecondary }]}>{steps[currentStep].description}</Text>
</View> </View>
{/* Step 0: Select services */}
{currentStep === 0 && ( {currentStep === 0 && (
<View style={styles.cardsContainer}> <View style={styles.cardsContainer}>
<ServiceToggleCard theme={theme} icon="📷" name="Google Photos" <ServiceToggleCard theme={theme} icon="📷" name="Google Photos"
@@ -192,12 +185,10 @@ const OnboardingScreen = ({ navigation, route }) => {
description="Connect to your Ubuntu server via SFTP" description="Connect to your Ubuntu server via SFTP"
enabled={nasEnabled} onToggle={() => setNasEnabled(!nasEnabled)} /> enabled={nasEnabled} onToggle={() => setNasEnabled(!nasEnabled)} />
{/* NAS setup instructions dropdown */}
{nasEnabled && <NASSetupInstructions />} {nasEnabled && <NASSetupInstructions />}
</View> </View>
)} )}
{/* Step 1: Configure each service */}
{currentStep === 1 && ( {currentStep === 1 && (
<View style={styles.cardsContainer}> <View style={styles.cardsContainer}>
{googleEnabled && ( {googleEnabled && (
@@ -281,7 +272,6 @@ const OnboardingScreen = ({ navigation, route }) => {
</View> </View>
)} )}
{/* Step 2: Auto-backup multi-checkbox */}
{currentStep === 2 && ( {currentStep === 2 && (
<View style={styles.cardsContainer}> <View style={styles.cardsContainer}>
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}> <View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
@@ -327,7 +317,6 @@ const OnboardingScreen = ({ navigation, route }) => {
)} )}
</ScrollView> </ScrollView>
{/* Footer */}
<View style={[styles.footer, { borderTopColor: theme.border }]}> <View style={[styles.footer, { borderTopColor: theme.border }]}>
{currentStep > 0 ? ( {currentStep > 0 ? (
<TouchableOpacity style={[styles.backButton, { backgroundColor: theme.surface, borderColor: theme.border }]} <TouchableOpacity style={[styles.backButton, { backgroundColor: theme.surface, borderColor: theme.border }]}
@@ -383,7 +372,6 @@ const styles = StyleSheet.create({
title: { fontSize: 26, fontWeight: '700', marginBottom: 6 }, title: { fontSize: 26, fontWeight: '700', marginBottom: 6 },
description: { fontSize: 14 }, description: { fontSize: 14 },
cardsContainer: { gap: 12 }, cardsContainer: { gap: 12 },
// Toggle cards (Step 0)
toggleCard: { toggleCard: {
borderRadius: 12, padding: 16, flexDirection: 'row', borderRadius: 12, padding: 16, flexDirection: 'row',
justifyContent: 'space-between', alignItems: 'center', borderWidth: 1.5, justifyContent: 'space-between', alignItems: 'center', borderWidth: 1.5,
@@ -393,7 +381,6 @@ const styles = StyleSheet.create({
toggleInfo: { flex: 1 }, toggleInfo: { flex: 1 },
toggleName: { fontSize: 15, fontWeight: '600', marginBottom: 2 }, toggleName: { fontSize: 15, fontWeight: '600', marginBottom: 2 },
toggleDescription: { fontSize: 12 }, toggleDescription: { fontSize: 12 },
// Config cards (Step 1)
configCard: { borderRadius: 12, padding: 16, borderWidth: 1 }, configCard: { borderRadius: 12, padding: 16, borderWidth: 1 },
configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 }, configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
configIcon: { fontSize: 24, marginRight: 10 }, configIcon: { fontSize: 24, marginRight: 10 },
@@ -405,7 +392,6 @@ const styles = StyleSheet.create({
fieldInput: { fieldInput: {
borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14, borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14,
}, },
// Auto-backup (Step 2)
checklistInfo: { fontSize: 13, marginBottom: 8 }, checklistInfo: { fontSize: 13, marginBottom: 8 },
checkRow: { checkRow: {
flexDirection: 'row', alignItems: 'center', paddingVertical: 14, flexDirection: 'row', alignItems: 'center', paddingVertical: 14,
@@ -418,7 +404,6 @@ const styles = StyleSheet.create({
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' }, checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' },
checkIcon: { fontSize: 18, marginRight: 8 }, checkIcon: { fontSize: 18, marginRight: 8 },
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 }, checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
// Footer
footer: { footer: {
flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14, flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14,
borderTopWidth: 1, gap: 12, borderTopWidth: 1, gap: 12,
-2
View File
@@ -90,7 +90,6 @@ const RegisterScreen = ({ navigation }) => {
/> />
</View> </View>
{/* Create Account button – blue */}
<TouchableOpacity <TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]} style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]}
onPress={handleRegister} onPress={handleRegister}
@@ -101,7 +100,6 @@ const RegisterScreen = ({ navigation }) => {
)} )}
</TouchableOpacity> </TouchableOpacity>
{/* Back to Login button – white/outlined */}
<TouchableOpacity <TouchableOpacity
style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]} style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]}
onPress={() => navigation.navigate('Login')} onPress={() => navigation.navigate('Login')}
+1 -25
View File
@@ -18,11 +18,7 @@ const SettingsScreen = ({ navigation, route }) => {
const [accounts, setAccounts] = useState([]); const [accounts, setAccounts] = useState([]);
const [autoBackup, setAutoBackup] = useState([]); const [autoBackup, setAutoBackup] = useState([]);
const [nasOffload, setNasOffload] = useState(false); const [nasOffload, setNasOffload] = useState(false);
// Per-service health: { GOOGLE_PHOTOS: 'healthy'|'error'|'inactive', ... }
const [serviceHealth, setServiceHealth] = useState({}); const [serviceHealth, setServiceHealth] = useState({});
// NAS edit fields
const [editingNas, setEditingNas] = useState(false); const [editingNas, setEditingNas] = useState(false);
const [nasHost, setNasHost] = useState(''); const [nasHost, setNasHost] = useState('');
const [nasPort, setNasPort] = useState('22'); const [nasPort, setNasPort] = useState('22');
@@ -51,14 +47,12 @@ const SettingsScreen = ({ navigation, route }) => {
setNasPhotoFolder(nasAcc.account_data.photoFolder || '/home/user/photos'); setNasPhotoFolder(nasAcc.account_data.photoFolder || '/home/user/photos');
} }
// Check health for each enabled account
checkAllServiceHealth(allAccounts); checkAllServiceHealth(allAccounts);
} catch (err) { } catch (err) {
console.error('Error loading settings:', err); console.error('Error loading settings:', err);
} }
}; };
// ─── Health checks ─────────────────────────────────────────
const checkAllServiceHealth = async (accs) => { const checkAllServiceHealth = async (accs) => {
const health = {}; const health = {};
for (const acc of accs) { for (const acc of accs) {
@@ -76,8 +70,6 @@ const SettingsScreen = ({ navigation, route }) => {
const checkSingleHealth = async (acc) => { const checkSingleHealth = async (acc) => {
try { try {
if (acc.service_type === 'GOOGLE_PHOTOS') { if (acc.service_type === 'GOOGLE_PHOTOS') {
// The Photos Library REST API is deprecated; health-check the token
// by calling the userinfo endpoint instead.
const gAuth = new GoogleAUTH(); const gAuth = new GoogleAUTH();
gAuth.accessToken = acc.account_data?.accessToken; gAuth.accessToken = acc.account_data?.accessToken;
gAuth.refreshToken = acc.account_data?.refreshToken; gAuth.refreshToken = acc.account_data?.refreshToken;
@@ -93,7 +85,6 @@ const SettingsScreen = ({ navigation, route }) => {
return 'healthy'; return 'healthy';
} }
if (acc.service_type === 'ICLOUD_PHOTOS') { if (acc.service_type === 'ICLOUD_PHOTOS') {
// Placeholder – always "healthy" since it's a stub
return 'healthy'; return 'healthy';
} }
return 'inactive'; return 'inactive';
@@ -108,7 +99,6 @@ const SettingsScreen = ({ navigation, route }) => {
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: status })); setServiceHealth((prev) => ({ ...prev, [acc.service_type]: status }));
}; };
// ─── Service actions ───────────────────────────────────────
const toggleService = async (accountId, currentEnabled) => { const toggleService = async (accountId, currentEnabled) => {
try { try {
await DatabaseService.toggleAccountEnabled(accountId, !currentEnabled); await DatabaseService.toggleAccountEnabled(accountId, !currentEnabled);
@@ -168,7 +158,7 @@ const SettingsScreen = ({ navigation, route }) => {
} else if (acc.service_type === 'NAS') { } else if (acc.service_type === 'NAS') {
setEditingNas(true); setEditingNas(true);
setLoading(false); setLoading(false);
return; // user edits inline return;
} }
await loadData(); await loadData();
} catch (error) { } catch (error) {
@@ -249,7 +239,6 @@ const SettingsScreen = ({ navigation, route }) => {
</View> </View>
<ScrollView style={styles.scrollContent} showsVerticalScrollIndicator={false}> <ScrollView style={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* ── Theme ──────────────────────────────────────── */}
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>APPEARANCE</Text> <Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>APPEARANCE</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}> <View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
{themeOptions.map((opt) => ( {themeOptions.map((opt) => (
@@ -272,7 +261,6 @@ const SettingsScreen = ({ navigation, route }) => {
))} ))}
</View> </View>
{/* ── Services ──────────────────────────────────── */}
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>SERVICES</Text> <Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>SERVICES</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}> <View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
{accounts.length === 0 ? ( {accounts.length === 0 ? (
@@ -286,7 +274,6 @@ const SettingsScreen = ({ navigation, route }) => {
const health = serviceHealth[acc.service_type] || (acc.is_enabled ? 'checking' : 'inactive'); const health = serviceHealth[acc.service_type] || (acc.is_enabled ? 'checking' : 'inactive');
return ( return (
<View key={acc.id}> <View key={acc.id}>
{/* Main row */}
<View style={[styles.serviceRow, { borderBottomColor: theme.borderLight }]}> <View style={[styles.serviceRow, { borderBottomColor: theme.borderLight }]}>
<TouchableOpacity onPress={() => recheckHealth(acc)} activeOpacity={0.6}> <TouchableOpacity onPress={() => recheckHealth(acc)} activeOpacity={0.6}>
<ServiceHealthIndicator status={health} size={10} /> <ServiceHealthIndicator status={health} size={10} />
@@ -307,7 +294,6 @@ const SettingsScreen = ({ navigation, route }) => {
thumbColor={acc.is_enabled ? theme.accent : '#f4f3f4'} thumbColor={acc.is_enabled ? theme.accent : '#f4f3f4'}
/> />
</View> </View>
{/* Action buttons row */}
<View style={[styles.serviceActions, { borderBottomColor: theme.borderLight }]}> <View style={[styles.serviceActions, { borderBottomColor: theme.borderLight }]}>
{acc.service_type === 'NAS' && acc.is_enabled && ( {acc.service_type === 'NAS' && acc.is_enabled && (
<TouchableOpacity <TouchableOpacity
@@ -339,7 +325,6 @@ const SettingsScreen = ({ navigation, route }) => {
)} )}
</View> </View>
{/* NAS Edit Config */}
{editingNas && ( {editingNas && (
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}> <View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.cardTitle, { color: theme.text }]}>Edit NAS Configuration</Text> <Text style={[styles.cardTitle, { color: theme.text }]}>Edit NAS Configuration</Text>
@@ -364,10 +349,8 @@ const SettingsScreen = ({ navigation, route }) => {
</View> </View>
)} )}
{/* NAS Setup Instructions */}
<NASSetupInstructions /> <NASSetupInstructions />
{/* NAS Google Photos Offloading */}
{hasNasConnected && ( {hasNasConnected && (
<> <>
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>NAS OFFLOADING</Text> <Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>NAS OFFLOADING</Text>
@@ -391,7 +374,6 @@ const SettingsScreen = ({ navigation, route }) => {
</> </>
)} )}
{/* ── Auto Backup ───────────────────────────────── */}
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>AUTOMATIC BACKUP</Text> <Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>AUTOMATIC BACKUP</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}> <View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.cardSubtext, { color: theme.textSecondary }]}> <Text style={[styles.cardSubtext, { color: theme.textSecondary }]}>
@@ -452,7 +434,6 @@ const SettingsScreen = ({ navigation, route }) => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* ── Sign Out ──────────────────────────────────── */}
<TouchableOpacity <TouchableOpacity
style={[styles.logoutButton, { borderColor: theme.danger }]} style={[styles.logoutButton, { borderColor: theme.danger }]}
onPress={handleLogout} onPress={handleLogout}
@@ -482,7 +463,6 @@ const styles = StyleSheet.create({
}, },
cardTitle: { fontSize: 15, fontWeight: '600', padding: 14, paddingBottom: 4 }, cardTitle: { fontSize: 15, fontWeight: '600', padding: 14, paddingBottom: 4 },
cardSubtext: { fontSize: 12, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4 }, cardSubtext: { fontSize: 12, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4 },
// Theme rows
themeRow: { themeRow: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
@@ -494,7 +474,6 @@ const styles = StyleSheet.create({
radio: { radio: {
width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: '#dadce0', width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: '#dadce0',
}, },
// Service rows
serviceRow: { serviceRow: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
@@ -513,7 +492,6 @@ const styles = StyleSheet.create({
actionBtnText: { fontSize: 12, fontWeight: '600' }, actionBtnText: { fontSize: 12, fontWeight: '600' },
emptyRow: { padding: 16 }, emptyRow: { padding: 16 },
emptyText: { fontSize: 13, textAlign: 'center' }, emptyText: { fontSize: 13, textAlign: 'center' },
// NAS fields
fieldLabel: { fontSize: 12, fontWeight: '600', marginTop: 10, marginBottom: 4, paddingHorizontal: 14 }, fieldLabel: { fontSize: 12, fontWeight: '600', marginTop: 10, marginBottom: 4, paddingHorizontal: 14 },
fieldInput: { fieldInput: {
borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10,
@@ -523,7 +501,6 @@ const styles = StyleSheet.create({
borderRadius: 8, paddingVertical: 12, alignItems: 'center', margin: 14, borderRadius: 8, paddingVertical: 12, alignItems: 'center', margin: 14,
}, },
saveButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' }, saveButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
// Auto backup checkbox rows
checkRow: { checkRow: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth, borderBottomWidth: StyleSheet.hairlineWidth,
@@ -536,7 +513,6 @@ const styles = StyleSheet.create({
checkIcon: { fontSize: 18, marginRight: 8 }, checkIcon: { fontSize: 18, marginRight: 8 },
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 }, checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
notConnected: { fontSize: 11 }, notConnected: { fontSize: 11 },
// Logout
logoutButton: { logoutButton: {
borderWidth: 1.5, borderRadius: 8, paddingVertical: 14, borderWidth: 1.5, borderRadius: 8, paddingVertical: 14,
alignItems: 'center', marginTop: 24, alignItems: 'center', marginTop: 24,
-15
View File
@@ -1,12 +1,6 @@
import * as Crypto from 'expo-crypto'; import * as Crypto from 'expo-crypto';
/**
* Handles password hashing (SHA-256) and base64 encoding of account data objects
*/
class CredentialEncryption { class CredentialEncryption {
/**
* Hash a password with SHA-256 and return a hex digest
*/
async hashPassword(password) { async hashPassword(password) {
try { try {
if (!password) throw new Error('Password is required'); if (!password) throw new Error('Password is required');
@@ -20,9 +14,6 @@ class CredentialEncryption {
} }
} }
/**
* Compare a plaintext password against a stored hash
*/
async verifyPassword(password, storedHash) { async verifyPassword(password, storedHash) {
try { try {
const hash = await this.hashPassword(password); const hash = await this.hashPassword(password);
@@ -33,9 +24,6 @@ class CredentialEncryption {
} }
} }
/**
* Encode an object as a base64 JSON string for database storage
*/
encrypt(data) { encrypt(data) {
try { try {
if (!data) throw new Error('No data provided to encrypt'); if (!data) throw new Error('No data provided to encrypt');
@@ -47,9 +35,6 @@ class CredentialEncryption {
} }
} }
/**
* Decode a base64 JSON string back to an object
*/
decrypt(encryptedData) { decrypt(encryptedData) {
try { try {
if (!encryptedData) return null; if (!encryptedData) return null;
+2 -27
View File
@@ -1,9 +1,3 @@
// 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'; import { logError } from './ErrorHandler';
@@ -12,8 +6,6 @@ let db = null;
let initPromise = null; let initPromise = null;
const DatabaseService = { const DatabaseService = {
// ─── Initialisation ────────────────────────────────────────
initDatabase: async () => { initDatabase: async () => {
if (initPromise) return initPromise; if (initPromise) return initPromise;
@@ -32,7 +24,6 @@ const DatabaseService = {
return false; 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,
@@ -68,7 +59,6 @@ const DatabaseService = {
); );
`); `);
// 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 (_) {}
@@ -94,8 +84,6 @@ const DatabaseService = {
return true; return true;
}, },
// ─── Users ─────────────────────────────────────────────────
createUser: async (username, email, password) => { createUser: async (username, email, password) => {
try { try {
if (!(await DatabaseService.ensureDb())) return null; if (!(await DatabaseService.ensureDb())) return null;
@@ -155,8 +143,6 @@ const DatabaseService = {
} }
}, },
// ─── Linked Accounts ──────────────────────────────────────
linkAccount: async (userId, serviceType, accountData, isDefault = false) => { linkAccount: async (userId, serviceType, accountData, isDefault = false) => {
try { try {
if (!(await DatabaseService.ensureDb())) return null; if (!(await DatabaseService.ensureDb())) return null;
@@ -171,7 +157,6 @@ const DatabaseService = {
return null; return null;
} }
// Replace any existing link for the same service type
await db.runAsync( await db.runAsync(
'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?', 'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?',
[userId, serviceType] [userId, serviceType]
@@ -286,8 +271,6 @@ const DatabaseService = {
} }
}, },
// ─── 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 {
if (!(await DatabaseService.ensureDb())) return null; if (!(await DatabaseService.ensureDb())) return null;
@@ -308,16 +291,12 @@ const DatabaseService = {
} }
}, },
/**
* Save a normalised media item object to the DB.
* Skips duplicates (same user_id + source_id).
*/
saveMediaItem: async (userId, item) => { saveMediaItem: async (userId, item) => {
try { try {
if (!(await DatabaseService.ensureDb())) return null; if (!(await DatabaseService.ensureDb())) return null;
if (!userId || !item?.source_service) return null; if (!userId || !item?.source_service)
return null;
// Skip if already exists
if (item.source_id) { if (item.source_id) {
const existing = await db.getFirstAsync( const existing = await db.getFirstAsync(
'SELECT id FROM media WHERE user_id = ? AND source_id = ?', 'SELECT id FROM media WHERE user_id = ? AND source_id = ?',
@@ -384,8 +363,6 @@ const DatabaseService = {
} }
}, },
// ─── Settings ──────────────────────────────────────────────
getAutoBackupServices: async (userId) => { getAutoBackupServices: async (userId) => {
try { try {
if (!(await DatabaseService.ensureDb())) return []; if (!(await DatabaseService.ensureDb())) return [];
@@ -413,8 +390,6 @@ const DatabaseService = {
} }
}, },
// ─── Lifecycle ─────────────────────────────────────────────
closeDatabase: async () => { closeDatabase: async () => {
try { try {
if (db?.closeAsync) await db.closeAsync(); if (db?.closeAsync) await db.closeAsync();
+1 -27
View File
@@ -1,18 +1,6 @@
import * as MediaLibrary from 'expo-media-library'; import * as MediaLibrary from 'expo-media-library';
/**
* Fetches photos and videos directly from the Android device media store.
*
* This replaces the deprecated Google Photos Library REST API.
* On Android, the media store includes all photos visible to the device —
* camera roll, screenshots, downloads, and images synced by the Google
* Photos app. No OAuth token is needed; only the MEDIA_LIBRARY permission.
*/
const DeviceMediaService = { const DeviceMediaService = {
/**
* Request media-library permission (camera roll / storage).
* Returns true if granted.
*/
requestPermission: async () => { requestPermission: async () => {
const { status } = await MediaLibrary.requestPermissionsAsync(); const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') { if (status !== 'granted') {
@@ -22,14 +10,6 @@ const DeviceMediaService = {
return true; return true;
}, },
/**
* Fetch a page of media assets from the device.
*
* @param {number} pageSize Number of items per page (default 100).
* @param {string} after Cursor returned by a previous call (for pagination).
* @param {string} mediaType 'photo' | 'video' | 'all' (default 'all').
* @returns {{ mediaItems: Array, endCursor: string|null, hasNextPage: boolean }}
*/
getPhotos: async (pageSize = 100, after = null, mediaType = 'all') => { getPhotos: async (pageSize = 100, after = null, mediaType = 'all') => {
const hasPermission = await DeviceMediaService.requestPermission(); const hasPermission = await DeviceMediaService.requestPermission();
if (!hasPermission) { if (!hasPermission) {
@@ -44,7 +24,7 @@ const DeviceMediaService = {
const options = { const options = {
first: pageSize, first: pageSize,
mediaType: mediaTypeFilter, mediaType: mediaTypeFilter,
sortBy: [[MediaLibrary.SortBy.creationTime, false]], // newest first sortBy: [[MediaLibrary.SortBy.creationTime, false]],
}; };
if (after) options.after = after; if (after) options.after = after;
@@ -73,9 +53,6 @@ const DeviceMediaService = {
}; };
}, },
/**
* Fetch all albums from the device (including smart albums).
*/
getAlbums: async () => { getAlbums: async () => {
const hasPermission = await DeviceMediaService.requestPermission(); const hasPermission = await DeviceMediaService.requestPermission();
if (!hasPermission) return []; if (!hasPermission) return [];
@@ -91,9 +68,6 @@ const DeviceMediaService = {
})); }));
}, },
/**
* Fetch assets inside a specific album.
*/
getAlbumPhotos: async (albumId, pageSize = 100, after = null) => { getAlbumPhotos: async (albumId, pageSize = 100, after = null) => {
const hasPermission = await DeviceMediaService.requestPermission(); const hasPermission = await DeviceMediaService.requestPermission();
if (!hasPermission) { if (!hasPermission) {
-21
View File
@@ -1,31 +1,10 @@
import { Alert } from 'react-native'; 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) => { export const logError = (tag, error) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error(`[${tag}] ${message}`); 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) => { export const alertError = (title, error, buttons) => {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.error(`[${title}] ${message}`); console.error(`[${title}] ${message}`);
-2
View File
@@ -2,8 +2,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
const THEME_KEY = 'app_theme_preference'; const THEME_KEY = 'app_theme_preference';
// Available theme definitions
const lightTheme = { const lightTheme = {
name: 'light', name: 'light',
background: '#ffffff', background: '#ffffff',