From f8e11fc3682075ffcba5e58b9ea85e6e7679e681 Mon Sep 17 00:00:00 2001 From: mudabbir-ahmad Date: Sun, 22 Feb 2026 19:19:55 +0000 Subject: [PATCH] added NAS configuration file; updated MediaViewer to support thumbnail carousel; improved error handling in NASAPI and NASAUTH; refactored various comments for clarity --- App.js | 9 +- ServerFiles/.env | 17 + ServerFiles/nas_config.txt | 2 + ServerFiles/package.json | 31 ++ ServerFiles/server.js | 376 +++++++++++++++++++++++ TEST/testGooglePhotosAPI.js | 91 ++++++ TEST/testNASAPI.js | 116 +++++++ app.json | 1 + babel.config.js | 8 + package.json | 5 +- src/API/AndroidAPI.js | 97 +++--- src/API/IOSAPI.js | 22 +- src/API/NASAPI.js | 224 ++++---------- src/AUTH/AuthServiceBase.js | 30 +- src/AUTH/GoogleAUTH.js | 31 +- src/AUTH/IOSAUTH.js | 31 +- src/AUTH/NASAUTH.js | 188 ++---------- src/components/MediaViewer.js | 202 +++++++++++- src/components/ServiceHealthIndicator.js | 10 +- src/screens/AlbumsScreen.js | 2 +- src/screens/HomeScreen.js | 1 + src/screens/LoginScreen.js | 5 +- src/screens/RegisterScreen.js | 2 +- src/screens/SearchScreen.js | 2 +- src/services/CredentialEncryption.js | 22 +- src/services/DatabaseService.js | 68 +--- src/services/ThemeContext.js | 1 + 27 files changed, 1036 insertions(+), 558 deletions(-) create mode 100644 ServerFiles/.env create mode 100644 ServerFiles/nas_config.txt create mode 100644 ServerFiles/package.json create mode 100644 ServerFiles/server.js create mode 100644 TEST/testGooglePhotosAPI.js create mode 100644 TEST/testNASAPI.js create mode 100644 babel.config.js diff --git a/App.js b/App.js index 36df805..b0c4ea6 100644 --- a/App.js +++ b/App.js @@ -17,10 +17,7 @@ import SettingsScreen from './src/screens/SettingsScreen'; const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); -/** - * MainTabs – Bottom tab navigator for logged-in users - * Tabs: Library, Search, Collections - */ +// Bottom tab navigator shown after login function MainTabs({ route }) { const { userId } = route.params || {}; const { theme } = useTheme(); @@ -73,9 +70,7 @@ function MainTabs({ route }) { ); } -/** - * AppNavigator – Root stack navigator - */ +// Root stack navigator function AppNavigator() { return ( diff --git a/ServerFiles/.env b/ServerFiles/.env new file mode 100644 index 0000000..6fd7a02 --- /dev/null +++ b/ServerFiles/.env @@ -0,0 +1,17 @@ +# SFTP Backend Server Configuration + +# Server port +PORT=3001 + +# JWT Secret (change this in production!) +JWT_SECRET=web-token-key + +# Node environment +NODE_ENV=development + +# Optional: Default SFTP settings (for testing) +# DEFAULT_SFTP_HOST=192.168.1.100 +# DEFAULT_SFTP_PORT=22 +# DEFAULT_SFTP_USERNAME=user +# DEFAULT_SFTP_PASSWORD=password + diff --git a/ServerFiles/nas_config.txt b/ServerFiles/nas_config.txt new file mode 100644 index 0000000..5acf427 --- /dev/null +++ b/ServerFiles/nas_config.txt @@ -0,0 +1,2 @@ +/home/user/photos + diff --git a/ServerFiles/package.json b/ServerFiles/package.json new file mode 100644 index 0000000..a061fb1 --- /dev/null +++ b/ServerFiles/package.json @@ -0,0 +1,31 @@ +{ + "name": "media-app-sftp-backend", + "version": "1.0.0", + "description": "SFTP Backend API for Media Aggregation App", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "sftp", + "backend", + "api", + "express" + ], + "author": "", + "license": "MIT", + "dependencies": { + "body-parser": "^2.2.2", + "cors": "^2.8.6", + "dotenv": "^16.6.1", + "express": "^4.22.1", + "express-fileupload": "^1.4.0", + "jsonwebtoken": "^9.0.2", + "node-fetch": "^2.7.0" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } +} diff --git a/ServerFiles/server.js b/ServerFiles/server.js new file mode 100644 index 0000000..9bc3045 --- /dev/null +++ b/ServerFiles/server.js @@ -0,0 +1,376 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const cors = require('cors'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const util = require('util'); +const { execFile } = require('child_process'); +const fileUpload = require('express-fileupload'); +require('dotenv').config({ path: path.join(__dirname, '.env') }); + +const fetch = require('node-fetch'); +const execFileAsync = util.promisify(execFile); + +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'); +let DEFAULT_PHOTO_DIR = '/home/user/photos'; +try { + const raw = fs.readFileSync(NAS_CONFIG_PATH, 'utf8').trim(); + if (raw) DEFAULT_PHOTO_DIR = raw.split('\n')[0].trim(); +} catch (_) { + // Config file missing — keep the hard-coded default +} + +// SSH/SFTP shell helpers + +function runCommand(cmd, args, options = {}) { + return execFileAsync(cmd, args, { + timeout: 30000, + maxBuffer: 10 * 1024 * 1024, + ...options, + }); +} + +function buildScpArgs(conn, source, dest) { + const args = ['-P', String(conn.port || 22), ...SSH_OPTIONS]; + if (conn.privateKeyPath) args.push('-i', conn.privateKeyPath); + args.push(source, dest); + return args; +} + +function buildSftpArgs(conn, batchFilePath) { + const args = ['-P', String(conn.port || 22), ...SSH_OPTIONS, '-b', batchFilePath]; + if (conn.privateKeyPath) args.push('-i', conn.privateKeyPath); + args.push(`${conn.username}@${conn.host}`); + return args; +} + +async function runSftpBatch(conn, batchCommands) { + const batchFilePath = path.join( + os.tmpdir(), + `sftp_batch_${Date.now()}_${Math.random().toString(36).slice(2)}.txt` + ); + fs.writeFileSync(batchFilePath, batchCommands.join('\n'), 'utf8'); + try { + const args = buildSftpArgs(conn, batchFilePath); + if (conn.password) { + const sshpass = process.env.SSHPASS_PATH || 'sshpass'; + return await runCommand(sshpass, ['-p', conn.password, 'sftp', ...args]); + } + return await runCommand('sftp', args); + } finally { + fs.unlinkSync(batchFilePath); + } +} + +// Express setup + +const app = express(); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(fileUpload()); +app.use(cors()); + +const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; +const connections = new Map(); + +app.use((req, res, next) => { + console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`); + next(); +}); + +// JWT verification middleware + +function verifyToken(req, res, next) { + const authHeader = req.headers.authorization; + const token = authHeader?.split(' ')[1]; + if (!token) return res.status(401).json({ error: 'No token provided' }); + + try { + const decoded = jwt.verify(token, SECRET_KEY); + req.connectionId = decoded.connectionId; + req.connection = connections.get(decoded.connectionId); + if (!req.connection) return res.status(401).json({ error: 'Connection expired or not found' }); + next(); + } catch (err) { + res.status(401).json({ error: 'Invalid token: ' + err.message }); + } +} + +// ─── REST Endpoints ────────────────────────────────────────────── + +// Health check + +app.get('/api/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + activeConnections: connections.size, + defaultPhotoDir: DEFAULT_PHOTO_DIR, + }); +}); + +// Return the current NAS photo directory read from nas_config.txt + +app.get('/api/config/photo-dir', (req, res) => { + res.json({ photoDir: DEFAULT_PHOTO_DIR }); +}); + +// Establish an SFTP connection (stores credentials, returns a JWT) + +app.post('/api/sftp/connect', (req, res) => { + const { host, port = 22, username, password } = req.body; + if (!host || !username || !password) { + return res.status(400).json({ error: 'Missing required fields: host, username, password' }); + } + + const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + connections.set(connectionId, { host, port, username, password, createdAt: new Date() }); + + const token = jwt.sign({ connectionId }, SECRET_KEY, { expiresIn: '24h' }); + console.log(`Connection prepared: ${host}:${port} (${connectionId})`); + + res.json({ + success: true, + connectionToken: token, + message: `Connection prepared for ${host}:${port}`, + defaultPhotoDir: DEFAULT_PHOTO_DIR, + }); +}); + +// List directory contents + +app.post('/api/sftp/list', verifyToken, async (req, res) => { + const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body; + const conn = req.connection; + + try { + const result = await runSftpBatch(conn, [`ls -l ${dirPath}`]); + const files = result.stdout + .split('\n') + .filter(Boolean) + .map((line) => { + const parts = line.split(/\s+/); + return { + name: parts.pop(), + isDirectory: parts[0]?.charAt(0) === 'd', + size: parseInt(parts[4], 10), + modifyTime: new Date(`${parts[5]} ${parts[6]} ${parts[7]} ${parts[8]}`), + permissions: parts[0], + }; + }); + + res.json({ path: dirPath, files, count: files.length }); + } catch (err) { + console.error('List directory error:', err.message); + res.status(500).json({ error: 'Failed to list directory: ' + err.message }); + } +}); + +// Download a file from the NAS + +app.get('/api/sftp/download', verifyToken, async (req, res) => { + const { path: filePath } = req.query; + if (!filePath) return res.status(400).json({ error: 'Missing path parameter' }); + + const conn = req.connection; + const filename = path.basename(filePath); + const tempFilePath = path.join(os.tmpdir(), filename); + + try { + await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath)); + res.download(tempFilePath, filename, () => { + try { fs.unlinkSync(tempFilePath); } catch (_) { /* already cleaned */ } + }); + } catch (err) { + console.error('Download error:', err.message); + res.status(500).json({ error: 'Download failed: ' + err.message }); + } +}); + +// Upload a file to the NAS + +app.post('/api/sftp/upload', verifyToken, async (req, res) => { + const { remotePath } = req.body; + if (!remotePath || !req.files?.file) { + return res.status(400).json({ error: 'Missing remotePath or file' }); + } + + const file = req.files.file; + const conn = req.connection; + const tempFilePath = path.join(os.tmpdir(), file.name); + + try { + await file.mv(tempFilePath); + await runCommand( + 'scp', + buildScpArgs(conn, tempFilePath, `${conn.username}@${conn.host}:${remotePath}`) + ); + fs.unlinkSync(tempFilePath); + console.log(`Uploaded: ${remotePath}`); + res.json({ success: true, remotePath, message: 'File uploaded' }); + } catch (err) { + console.error('Upload error:', err.message); + res.status(500).json({ error: 'Upload failed: ' + err.message }); + } +}); + +// Create a directory on the NAS + +app.post('/api/sftp/mkdir', verifyToken, async (req, res) => { + const { path: dirPath } = req.body; + if (!dirPath) return res.status(400).json({ error: 'Missing path parameter' }); + + try { + await runSftpBatch(req.connection, [`mkdir ${dirPath}`]); + console.log(`Created directory: ${dirPath}`); + res.json({ success: true, path: dirPath, message: 'Directory created' }); + } catch (err) { + console.error('Mkdir error:', err.message); + res.status(500).json({ error: 'Failed to create directory: ' + err.message }); + } +}); + +// Delete a file on the NAS + +app.post('/api/sftp/delete', verifyToken, async (req, res) => { + const { path: filePath } = req.body; + if (!filePath) return res.status(400).json({ error: 'Missing path parameter' }); + + try { + await runSftpBatch(req.connection, [`rm ${filePath}`]); + console.log(`Deleted: ${filePath}`); + res.json({ success: true, path: filePath, message: 'File deleted' }); + } catch (err) { + console.error('Delete error:', err.message); + res.status(500).json({ error: 'Failed to delete file: ' + err.message }); + } +}); + +// Disconnect from the NAS + +app.post('/api/sftp/disconnect', verifyToken, (req, res) => { + connections.delete(req.connectionId); + console.log(`Disconnected: ${req.connectionId}`); + 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) => { + const { googleAccessToken, pageSize = 50, pageToken } = req.body; + if (!googleAccessToken) { + return res.status(400).json({ error: 'Google access token is required' }); + } + + try { + const params = new URLSearchParams({ pageSize: String(pageSize) }); + if (pageToken) params.append('pageToken', pageToken); + + const response = await fetch( + `https://photoslibrary.googleapis.com/v1/mediaItems?${params.toString()}`, + { headers: { Authorization: `Bearer ${googleAccessToken}` } } + ); + + if (!response.ok) { + const errText = await response.text(); + return res.status(response.status).json({ error: 'Google API error', details: errText }); + } + + const data = await response.json(); + const mediaItems = (data.mediaItems || []).map((item) => ({ + id: item.id, + filename: item.filename, + mimeType: item.mimeType, + baseUrl: item.baseUrl, + productUrl: item.productUrl, + creationTime: item.mediaMetadata?.creationTime, + width: item.mediaMetadata?.width, + height: item.mediaMetadata?.height, + })); + + console.log(`Fetched ${mediaItems.length} Google Photos items via NAS proxy`); + res.json({ success: true, mediaItems, nextPageToken: data.nextPageToken || null, count: mediaItems.length }); + } catch (err) { + console.error('Google Photos fetch error:', err.message); + res.status(500).json({ error: 'Failed to fetch Google Photos: ' + err.message }); + } +}); + +// Download a Google Photos item and save it directly to the NAS + +app.post('/api/google-photos/download-to-nas', verifyToken, async (req, res) => { + const { googleAccessToken, baseUrl, filename, remotePath } = req.body; + const conn = req.connection; + + if (!googleAccessToken || !baseUrl || !remotePath) { + return res.status(400).json({ error: 'googleAccessToken, baseUrl, and remotePath are required' }); + } + + try { + const imageResponse = await fetch(`${baseUrl}=d`, { + headers: { Authorization: `Bearer ${googleAccessToken}` }, + }); + if (!imageResponse.ok) { + return res.status(imageResponse.status).json({ error: 'Failed to download from Google Photos' }); + } + + const buffer = await imageResponse.buffer(); + const safeName = filename || `gphoto_${Date.now()}.jpg`; + const tempFilePath = path.join(os.tmpdir(), safeName); + fs.writeFileSync(tempFilePath, buffer); + + const fullRemotePath = `${conn.username}@${conn.host}:${remotePath}/${safeName}`; + await runCommand('scp', buildScpArgs(conn, tempFilePath, fullRemotePath)); + fs.unlinkSync(tempFilePath); + + console.log(`Saved Google Photo to NAS: ${remotePath}/${safeName}`); + res.json({ success: true, message: `Photo saved to NAS at ${remotePath}/${safeName}` }); + } catch (err) { + console.error('Download-to-NAS error:', err.message); + res.status(500).json({ error: 'Failed to save photo to NAS: ' + err.message }); + } +}); + +// Sync status (placeholder for future batch sync) + +app.post('/api/google-photos/sync-status', verifyToken, (req, res) => { + res.json({ success: true, status: 'idle', message: 'No active sync in progress', lastSync: null }); +}); + +// ─── Lifecycle ─────────────────────────────────────────────────── + +process.on('SIGINT', () => { + console.log('\nShutting down...'); + connections.forEach((_, id) => connections.delete(id)); + process.exit(0); +}); + +app.use((err, req, res, _next) => { + console.error('Server error:', err.message); + res.status(500).json({ + error: 'Internal server error', + message: process.env.NODE_ENV === 'development' ? err.message : 'Unknown error', + }); +}); + +app.use((req, res) => { + res.status(404).json({ error: 'Not found', path: req.path }); +}); + +const PORT = process.env.PORT || 3001; +app.listen(PORT, () => { + console.log(`\n SFTP Backend Server`); + console.log(` Port: ${PORT}`); + console.log(` URL: http://localhost:${PORT}`); + console.log(` Photo Dir: ${DEFAULT_PHOTO_DIR}`); + console.log(` Status: Ready\n`); +}); + +module.exports = app; + diff --git a/TEST/testGooglePhotosAPI.js b/TEST/testGooglePhotosAPI.js new file mode 100644 index 0000000..6c83a34 --- /dev/null +++ b/TEST/testGooglePhotosAPI.js @@ -0,0 +1,91 @@ +/** + * Google Photos OAuth & API Test + * + * Usage: + * node TEST/testGooglePhotosAPI.js + * GOOGLE_ACCESS_TOKEN=ya29.xxx node TEST/testGooglePhotosAPI.js + * + * How to get a token for manual testing: + * 1. Go to https://developers.google.com/oauthplayground + * 2. Select "Photos Library API v1" → photoslibrary scope + * 3. Authorise the API + * 4. Exchange the authorization code for tokens + * 5. Copy the access token and pass it to this script + * + * Tests: + * 1. Validates the token against the userinfo endpoint + * 2. Lists media items from Google Photos + * 3. Lists albums from Google Photos + */ + +const API_BASE = 'https://photoslibrary.googleapis.com/v1'; +const USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo'; + +async function main() { + const token = process.argv[2] || process.env.GOOGLE_ACCESS_TOKEN; + + if (!token) { + console.error('\nNo access token provided.'); + console.error('Usage: 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('and authorise the Photos Library API v1 scope.\n'); + process.exit(1); + } + + const headers = { Authorization: `Bearer ${token}` }; + + console.log('\n--- Google Photos API Test ---\n'); + + // Test 1 — User info + console.log('[1] Fetching user info...'); + try { + const res = await fetch(USERINFO_URL, { headers }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + const user = await res.json(); + console.log(` Authenticated as: ${user.email} (${user.name})\n`); + } catch (err) { + console.log(` Failed: ${err.message}\n`); + } + + // Test 2 — List media items + console.log('[2] Listing media items (first 10)...'); + try { + const res = await fetch(`${API_BASE}/mediaItems?pageSize=10`, { headers }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + const data = await res.json(); + const items = data.mediaItems || []; + console.log(` Found ${items.length} items`); + items.slice(0, 5).forEach((item, i) => { + console.log(` ${i + 1}. ${item.filename} (${item.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`); + } + + // Test 3 — List albums + console.log('[3] Listing albums...'); + try { + const res = await fetch(`${API_BASE}/albums?pageSize=10`, { headers }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + const data = await res.json(); + const albums = data.albums || []; + console.log(` Found ${albums.length} albums`); + albums.slice(0, 5).forEach((album, i) => { + console.log(` ${i + 1}. "${album.title}" - ${album.mediaItemsCount || 0} items`); + }); + console.log(); + } catch (err) { + console.log(` Failed: ${err.message}\n`); + } + + console.log('--- Tests complete ---\n'); +} + +main().catch((err) => { + console.error('Unhandled error:', err); + process.exit(1); +}); diff --git a/TEST/testNASAPI.js b/TEST/testNASAPI.js new file mode 100644 index 0000000..716b9af --- /dev/null +++ b/TEST/testNASAPI.js @@ -0,0 +1,116 @@ +/** + * 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'; + +async function main() { + console.log('\n--- NAS Backend API Test ---'); + console.log(`Backend: ${BACKEND_URL}\n`); + + // 1 — Health check + console.log('[1] Health check...'); + try { + const res = await fetch(`${BACKEND_URL}/api/health`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + console.log(` Status: ${data.status}, connections: ${data.activeConnections}, photo dir: ${data.defaultPhotoDir}\n`); + } catch (err) { + console.log(` Failed: ${err.message}`); + console.log(' Start the backend first: cd ServerFiles && node server.js\n'); + process.exit(1); + } + + const host = process.env.NAS_HOST; + const port = parseInt(process.env.NAS_PORT || '22', 10); + const username = process.env.NAS_USER; + const password = process.env.NAS_PASS; + const folder = process.env.NAS_FOLDER || '/home/user/photos'; + + if (!host || !username || !password) { + console.log('[2-4] Skipped — set NAS_HOST, NAS_USER, NAS_PASS to run connection tests.\n'); + console.log('--- Tests complete (partial) ---\n'); + return; + } + + // 2 — Connect + let connectionToken = null; + console.log(`[2] SFTP connect to ${host}:${port}...`); + try { + const res = await fetch(`${BACKEND_URL}/api/sftp/connect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ host, port, username, password }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Connect failed'); + connectionToken = data.connectionToken; + console.log(` Connected.\n`); + } catch (err) { + console.log(` Failed: ${err.message}\n`); + process.exit(1); + } + + const authHeaders = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${connectionToken}`, + }; + + // 3 — List directory + console.log(`[3] List directory "${folder}"...`); + try { + const res = await fetch(`${BACKEND_URL}/api/sftp/list`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ path: folder }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + const data = await res.json(); + console.log(` Found ${data.count || 0} items in ${data.path}`); + (data.files || []).slice(0, 5).forEach((f, i) => { + console.log(` ${i + 1}. ${f.name} ${f.isDirectory ? '(dir)' : `(${f.size} bytes)`}`); + }); + console.log(); + } catch (err) { + console.log(` Failed: ${err.message}\n`); + } + + // 4 — Disconnect + console.log('[4] Disconnect...'); + try { + const res = await fetch(`${BACKEND_URL}/api/sftp/disconnect`, { + method: 'POST', + headers: authHeaders, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + console.log(` ${data.message}\n`); + } catch (err) { + console.log(` Failed: ${err.message}\n`); + } + + console.log('--- Tests complete ---\n'); +} + +main().catch((err) => { + console.error('Unhandled error:', err); + process.exit(1); +}); diff --git a/app.json b/app.json index cdb30ec..368e1c3 100644 --- a/app.json +++ b/app.json @@ -25,6 +25,7 @@ "web": { "favicon": "./assets/favicon.png" }, + "scheme": "media-aggregation", "plugins": [ "expo-sqlite" ] diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..ce05ac6 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,8 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: ['react-native-reanimated/plugin'], + }; +}; + diff --git a/package.json b/package.json index eaaa7dd..74b0faa 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,11 @@ "expo-web-browser": "~15.0.10", "react": "19.1.0", "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", + "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0" + "react-native-screens": "~4.16.0", + "react-native-worklets": "0.5.1" }, "devDependencies": { "@babel/core": "^7.29.0" diff --git a/src/API/AndroidAPI.js b/src/API/AndroidAPI.js index 6ca0767..8519b94 100644 --- a/src/API/AndroidAPI.js +++ b/src/API/AndroidAPI.js @@ -1,71 +1,46 @@ +// Google Photos Library API wrapper — uses the provided access token getter for authenticated requests const GooglePhotosAPI = (getAccessToken) => { - const callFetch = async (endpoint, method = 'GET', dataObj = null) => { - const token = await getAccessToken(); - if (!token) return { isSuccess: false, message: 'Missing access token' }; + const callFetch = async (endpoint, method = 'GET', dataObj = null) => { + const token = await getAccessToken(); + if (!token) return { isSuccess: false, message: 'Missing access token' }; - let requestObj = { - method, - headers: { - Authorization: `Bearer ${token}`, - }, - }; - - if (dataObj) { - requestObj = { - ...requestObj, - headers: { - ...requestObj.headers, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(dataObj), - }; - } - - try { - const response = await fetch(endpoint, requestObj); - const result = response.status !== 204 ? await response.json() : null; - return response.ok - ? { isSuccess: true, result } - : { isSuccess: false, message: result?.error?.message || 'Request failed' }; - } catch (error) { - return { isSuccess: false, message: error.message }; - } + let requestObj = { + method, + headers: { Authorization: `Bearer ${token}` }, }; - const API_ROOT = 'https://photoslibrary.googleapis.com/v1'; + if (dataObj) { + requestObj.headers['Content-Type'] = 'application/json'; + requestObj.body = JSON.stringify(dataObj); + } - return { - // List media items - listMediaItems: (pageSize = 25, pageToken) => { - const params = new URLSearchParams({ pageSize }); - if (pageToken) params.append('pageToken', pageToken); - return callFetch(`${API_ROOT}/mediaItems?${params.toString()}`, 'GET'); - }, + try { + const response = await fetch(endpoint, requestObj); + const result = response.status !== 204 ? await response.json() : null; + return response.ok + ? { isSuccess: true, result } + : { isSuccess: false, message: result?.error?.message || 'Request failed' }; + } catch (error) { + return { isSuccess: false, message: error.message }; + } + }; - // Get a single media item by ID - getMediaItem: (mediaItemId) => - callFetch(`${API_ROOT}/mediaItems/${mediaItemId}`, 'GET'), + const API_ROOT = 'https://photoslibrary.googleapis.com/v1'; - // Search media items with filters (e.g., albumId) - searchMediaItems: (filters) => - callFetch(`${API_ROOT}/mediaItems:search`, 'POST', filters), - - // Create an album - createAlbum: (title) => - callFetch(`${API_ROOT}/albums`, 'POST', { album: { title } }), - - // Add media items to an album - addItemsToAlbum: (albumId, mediaItemIds) => - callFetch(`${API_ROOT}/albums/${albumId}:batchAddMediaItems`, 'POST', { - mediaItemIds, - }), - - // Remove media items from an album - removeItemsFromAlbum: (albumId, mediaItemIds) => - callFetch(`${API_ROOT}/albums/${albumId}:batchRemoveMediaItems`, 'POST', { - mediaItemIds, - }), - }; + return { + listMediaItems: (pageSize = 25, pageToken) => { + const params = new URLSearchParams({ pageSize }); + if (pageToken) params.append('pageToken', pageToken); + return callFetch(`${API_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 }), + }; }; export default GooglePhotosAPI; diff --git a/src/API/IOSAPI.js b/src/API/IOSAPI.js index 60edc71..4f05695 100644 --- a/src/API/IOSAPI.js +++ b/src/API/IOSAPI.js @@ -1,18 +1,8 @@ -/** - * iCloud Photos API - Placeholder - * - * Apple does not provide a public REST API for iCloud Photos. - * This file is reserved for future integration if Apple provides API access - * or if a third-party solution becomes available. - */ - -const iCloudPhotosAPI = () => { - return { - listMediaItems: async () => ({ isSuccess: false, message: 'iCloud API not available' }), - getMediaItem: async () => ({ isSuccess: false, message: 'iCloud API not available' }), - getAlbums: async () => ({ isSuccess: false, message: 'iCloud API not available' }), - }; -}; +// iCloud Photos API placeholder — Apple does not provide a public REST API for iCloud Photos +const iCloudPhotosAPI = () => ({ + listMediaItems: async () => ({ isSuccess: false, message: 'iCloud API not available' }), + getMediaItem: async () => ({ isSuccess: false, message: 'iCloud API not available' }), + getAlbums: async () => ({ isSuccess: false, message: 'iCloud API not available' }), +}); export default iCloudPhotosAPI; - diff --git a/src/API/NASAPI.js b/src/API/NASAPI.js index 273dd5d..8707648 100644 --- a/src/API/NASAPI.js +++ b/src/API/NASAPI.js @@ -1,13 +1,6 @@ import axios from 'axios'; -/** - * NAS API - HTTP Client for SFTP Backend - * - * Connects to the Node.js backend server (ServerFiles/server.js) - * which handles all actual SSH/SFTP operations. - */ - -// Default backend URL - update this to match your server address +// HTTP client that communicates with the SFTP backend (ServerFiles/server.js) const DEFAULT_BACKEND_URL = 'http://192.168.1.100:3001'; class NASAPI { @@ -18,113 +11,72 @@ class NASAPI { this.credentials = null; } - /** - * Connect to NAS via the backend server - * The backend handles the actual SSH connection - */ + // 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') { + if (!host || !username || !password) throw new Error('Host, username, and password are required'); + + this.credentials = { host, port: port || 22, username, photoFolder }; + try { - if (!host || !username || !password) { - throw new Error('Host, username, and password are required'); + const response = await axios.post( + `${this.backendUrl}/api/sftp/connect`, + { host, port: port || 22, username, password }, + { timeout: 10000 } + ); + if (response.data?.success) { + this.connectionToken = response.data.connectionToken; + this.isConnected = true; + return { success: true, message: `Connected to ${host}` }; } - - // Store credentials locally - this.credentials = { host, port: port || 22, username, photoFolder }; - - // Try to connect via backend - try { - const response = await axios.post( - `${this.backendUrl}/api/sftp/connect`, - { - host, - port: port || 22, - username, - password, - }, - { timeout: 10000 } - ); - - if (response.data?.success) { - this.connectionToken = response.data.connectionToken; - this.isConnected = true; - console.log(`NAS connected via backend: ${host}:${port}`); - return { success: true, message: `Connected to ${host}` }; - } - } catch (backendErr) { - // Backend not available - store credentials for when it becomes available - console.log('Backend not available, storing NAS credentials locally'); - } - - // Even without backend, store config so user can proceed - this.isConnected = true; - return { - success: true, - message: `NAS configured: ${host} (backend connection pending)`, - connectionData: this.credentials, - }; - } catch (error) { - console.error('NAS connection error:', error); - throw error; + } catch (_) { + console.log('Backend not available, storing NAS credentials locally'); } + + this.isConnected = true; + return { success: true, message: `NAS configured: ${host} (backend connection pending)`, connectionData: this.credentials }; } - /** - * Get authorization headers for backend requests - */ getHeaders() { if (!this.connectionToken) return {}; return { Authorization: `Bearer ${this.connectionToken}` }; } - /** - * List directory contents on the NAS - */ async listDirectory(path = '/') { - if (!this.connectionToken) { - return { path, files: [], message: 'Backend not connected' }; - } - try { - const response = await axios.post( - `${this.backendUrl}/api/sftp/list`, - { path }, - { headers: this.getHeaders(), timeout: 15000 } - ); - return response.data; - } catch (error) { - console.error('List directory error:', error.message); - throw new Error('Failed to list directory: ' + error.message); - } + if (!this.connectionToken) return { path, files: [], message: 'Backend not connected' }; + const response = await axios.post( + `${this.backendUrl}/api/sftp/list`, { path }, + { headers: this.getHeaders(), timeout: 15000 } + ); + return response.data; } - /** - * Get photos from designated NAS folder - */ + // Filter directory listing for supported photo/video files async getPhotos(folder = null) { 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' }; try { const listing = await this.listDirectory(targetFolder); - const photoExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic']; - const videoExtensions = ['.mp4', '.mov', '.avi', '.mkv', '.webm']; + const photoExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic']; + const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm']; const mediaItems = (listing.files || []) .filter((file) => { - const ext = (file.name || '').toLowerCase().split('.').pop(); - return photoExtensions.includes('.' + ext) || videoExtensions.includes('.' + ext); + const ext = '.' + (file.name || '').toLowerCase().split('.').pop(); + return photoExts.includes(ext) || videoExts.includes(ext); }) - .map((file) => ({ - source_service: 'NAS', - source_id: `nas_${file.name}`, - title: file.name, - remote_path: `${targetFolder}/${file.name}`, - media_type: videoExtensions.includes('.' + (file.name || '').toLowerCase().split('.').pop()) ? 'video' : 'photo', - created_date: file.modifyTime, - size: file.size, - })); + .map((file) => { + const ext = '.' + (file.name || '').toLowerCase().split('.').pop(); + return { + source_service: 'NAS', + source_id: `nas_${file.name}`, + title: file.name, + remote_path: `${targetFolder}/${file.name}`, + media_type: videoExts.includes(ext) ? 'video' : 'photo', + created_date: file.modifyTime, + size: file.size, + }; + }); return { mediaItems, folder: targetFolder }; } catch (error) { @@ -133,84 +85,42 @@ class NASAPI { } } - /** - * Upload file to NAS - */ async uploadFile(localPath, remotePath) { - if (!this.connectionToken) { - throw new Error('Backend not connected - cannot upload'); - } - try { - const formData = new FormData(); - formData.append('remotePath', remotePath); - formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' }); + if (!this.connectionToken) throw new Error('Backend not connected — cannot upload'); + const formData = new FormData(); + formData.append('remotePath', remotePath); + formData.append('file', { uri: localPath, name: remotePath.split('/').pop(), type: 'application/octet-stream' }); - const response = await axios.post( - `${this.backendUrl}/api/sftp/upload`, - formData, - { headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 } - ); - return response.data; - } catch (error) { - console.error('Upload error:', error.message); - throw new Error('Upload failed: ' + error.message); - } + const response = await axios.post( + `${this.backendUrl}/api/sftp/upload`, formData, + { headers: { ...this.getHeaders(), 'Content-Type': 'multipart/form-data' }, timeout: 60000 } + ); + return response.data; } - /** - * Create directory on NAS - */ async createDirectory(remotePath) { - if (!this.connectionToken) { - throw new Error('Backend not connected'); - } - try { - const response = await axios.post( - `${this.backendUrl}/api/sftp/mkdir`, - { path: remotePath }, - { headers: this.getHeaders(), timeout: 10000 } - ); - return response.data; - } catch (error) { - console.error('Create directory error:', error.message); - throw error; - } + if (!this.connectionToken) throw new Error('Backend not connected'); + const response = await axios.post( + `${this.backendUrl}/api/sftp/mkdir`, { path: remotePath }, + { headers: this.getHeaders(), timeout: 10000 } + ); + return response.data; } - /** - * Delete file from NAS - */ async deleteFile(remotePath) { - if (!this.connectionToken) { - throw new Error('Backend not connected'); - } - try { - const response = await axios.post( - `${this.backendUrl}/api/sftp/delete`, - { path: remotePath }, - { headers: this.getHeaders(), timeout: 10000 } - ); - return response.data; - } catch (error) { - console.error('Delete error:', error.message); - throw error; - } + if (!this.connectionToken) throw new Error('Backend not connected'); + const response = await axios.post( + `${this.backendUrl}/api/sftp/delete`, { path: remotePath }, + { headers: this.getHeaders(), timeout: 10000 } + ); + return response.data; } - /** - * Disconnect from NAS - */ async disconnect() { if (this.connectionToken) { try { - await axios.post( - `${this.backendUrl}/api/sftp/disconnect`, - {}, - { headers: this.getHeaders(), timeout: 5000 } - ); - } catch (err) { - // Ignore disconnect errors - } + await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 }); + } catch (_) { /* ignore */ } } this.connectionToken = null; this.isConnected = false; diff --git a/src/AUTH/AuthServiceBase.js b/src/AUTH/AuthServiceBase.js index aea0ff6..cd244e0 100644 --- a/src/AUTH/AuthServiceBase.js +++ b/src/AUTH/AuthServiceBase.js @@ -1,7 +1,4 @@ -/** - * Abstract base class for authentication services - * This provides a common interface for different authentication providers - */ +// Abstract base class for authentication services — provides a common interface for all providers export class AuthServiceBase { constructor() { if (new.target === AuthServiceBase) { @@ -9,26 +6,11 @@ export class AuthServiceBase { } } - async authenticate() { - throw new Error('authenticate() must be implemented'); - } - - async getAccessToken() { - throw new Error('getAccessToken() must be implemented'); - } - - async refreshToken() { - throw new Error('refreshToken() must be implemented'); - } - - async revokeAccess() { - throw new Error('revokeAccess() must be implemented'); - } - - async getUserInfo() { - throw new Error('getUserInfo() must be implemented'); - } + async authenticate() { throw new Error('authenticate() must be implemented'); } + async getAccessToken() { throw new Error('getAccessToken() must be implemented'); } + async refreshToken() { throw new Error('refreshToken() must be implemented'); } + async revokeAccess() { throw new Error('revokeAccess() must be implemented'); } + async getUserInfo() { throw new Error('getUserInfo() must be implemented'); } } export default AuthServiceBase; - diff --git a/src/AUTH/GoogleAUTH.js b/src/AUTH/GoogleAUTH.js index 495c00e..a39ae66 100644 --- a/src/AUTH/GoogleAUTH.js +++ b/src/AUTH/GoogleAUTH.js @@ -4,7 +4,7 @@ import { AuthServiceBase } from './AuthServiceBase'; WebBrowser.maybeCompleteAuthSession(); -// TODO: Replace with your real Google Cloud OAuth Client ID +// Replace with your real Google Cloud OAuth Client ID (see ignorefiles/GOOGLE_OAUTH_SETUP.md) const GOOGLE_CLIENT_ID = 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com'; const GOOGLE_DISCOVERY = { @@ -13,7 +13,7 @@ const GOOGLE_DISCOVERY = { revocationEndpoint: 'https://oauth2.googleapis.com/revoke', }; -// Google Photos Library API scopes - read and write +// Google Photos Library API scopes — read, write, and user profile const SCOPES = [ 'https://www.googleapis.com/auth/photoslibrary', 'https://www.googleapis.com/auth/photoslibrary.appendonly', @@ -31,19 +31,15 @@ class GoogleAUTH extends AuthServiceBase { this.idToken = null; } - /** - * Authenticate with Google using expo-auth-session - * Falls back to placeholder tokens when no real client ID is set - */ + // Authenticate via Google OAuth; uses placeholder tokens when no real client ID is configured async authenticate() { try { if (GOOGLE_CLIENT_ID === 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com') { - console.log('Google Auth: Using placeholder mode (no client ID configured)'); + console.log('Google Auth: placeholder mode (no client ID configured)'); this.accessToken = 'placeholder_google_token_' + Date.now(); this.refreshToken = 'placeholder_refresh_' + Date.now(); this.expiresAt = Date.now() + 3600000; this.email = 'google-user@placeholder.com'; - return { success: true, accessToken: this.accessToken, @@ -52,8 +48,10 @@ class GoogleAUTH extends AuthServiceBase { }; } - // Real OAuth flow - const redirectUri = AuthSession.makeRedirectUri({ preferLocalhost: true }); + const redirectUri = AuthSession.makeRedirectUri({ + scheme: 'media-aggregation', + preferLocalhost: true, + }); const authRequest = new AuthSession.AuthRequest({ clientId: GOOGLE_CLIENT_ID, @@ -114,6 +112,7 @@ class GoogleAUTH extends AuthServiceBase { } } + // Return a valid access token, refreshing if expired async getAccessToken() { if (this.accessToken && this.expiresAt > Date.now()) return this.accessToken; if (this.refreshToken) return await this.refreshAccessToken(); @@ -165,9 +164,7 @@ class GoogleAUTH extends AuthServiceBase { return info || { id: 'google_user', email: this.email || 'unknown', name: 'Google User' }; } - /** - * Fetch media items from Google Photos Library API - */ + // Fetch media items from the Google Photos Library API async getPhotos(pageSize = 50, pageToken = null) { try { const token = await this.getAccessToken(); @@ -204,9 +201,7 @@ class GoogleAUTH extends AuthServiceBase { } } - /** - * Fetch albums from Google Photos Library API - */ + // Fetch album list from the Google Photos Library API async getAlbums(pageSize = 50, pageToken = null) { try { const token = await this.getAccessToken(); @@ -227,9 +222,7 @@ class GoogleAUTH extends AuthServiceBase { } } - /** - * Upload media to Google Photos Library - */ + // Upload a photo to Google Photos Library async uploadPhoto(filePath, filename, mimeType = 'image/jpeg') { try { const token = await this.getAccessToken(); diff --git a/src/AUTH/IOSAUTH.js b/src/AUTH/IOSAUTH.js index bc4b489..4a5109f 100644 --- a/src/AUTH/IOSAUTH.js +++ b/src/AUTH/IOSAUTH.js @@ -1,10 +1,6 @@ import { AuthServiceBase } from './AuthServiceBase'; -/** - * iCloud Photos Authentication Service - * Placeholder - Apple does not provide a public iCloud Photos API. - * This is kept for future integration if/when Apple provides API access. - */ +// iCloud Photos placeholder — Apple does not provide a public iCloud Photos API class IOSAUTH extends AuthServiceBase { constructor() { super(); @@ -12,13 +8,8 @@ class IOSAUTH extends AuthServiceBase { } async authenticate() { - console.log('iCloud Auth: Placeholder - no Apple API available'); this.accessToken = 'icloud_placeholder_' + Date.now(); - return { - success: true, - accessToken: this.accessToken, - message: 'iCloud placeholder - full integration pending', - }; + return { success: true, accessToken: this.accessToken, message: 'iCloud placeholder — full integration pending' }; } async getAccessToken() { @@ -26,26 +17,16 @@ class IOSAUTH extends AuthServiceBase { throw new Error('Not authenticated with iCloud'); } - async refreshToken() { - return this.authenticate(); - } + async refreshToken() { return this.authenticate(); } async revokeAccess() { this.accessToken = null; return { success: true }; } - async getUserInfo() { - return { id: 'icloud_placeholder', name: 'iCloud User' }; - } - - async getPhotos() { - return { mediaItems: [], nextPageToken: null }; - } - - async getAlbums() { - return { albums: [], nextPageToken: null }; - } + async getUserInfo() { return { id: 'icloud_placeholder', name: 'iCloud User' }; } + async getPhotos() { return { mediaItems: [], nextPageToken: null }; } + async getAlbums() { return { albums: [], nextPageToken: null }; } } export default IOSAUTH; diff --git a/src/AUTH/NASAUTH.js b/src/AUTH/NASAUTH.js index ada672c..6b50d31 100644 --- a/src/AUTH/NASAUTH.js +++ b/src/AUTH/NASAUTH.js @@ -1,12 +1,7 @@ import { AuthServiceBase } from './AuthServiceBase'; import NASAPI from '../API/NASAPI'; -/** - * NAS SFTP Authentication Service - * - * Uses NASAPI for direct NAS connection management - * No backend server required - */ +// NAS SFTP authentication — connects to the backend server via NASAPI class NASAUTH extends AuthServiceBase { constructor() { super(); @@ -19,17 +14,9 @@ class NASAUTH extends AuthServiceBase { this.isConnected = false; } - /** - * Authenticate with NAS/SFTP server - */ async authenticate(host, port = 22, username, password, photoFolder = '/Photos') { try { - // Validate input - if (!host || !username || !password) { - throw new Error('Host, username, and password are required'); - } - - console.log(`Connecting to NAS at ${host}:${port}...`); + if (!host || !username || !password) throw new Error('Host, username, and password are required'); this.host = host; this.port = port || 22; @@ -37,17 +24,10 @@ class NASAUTH extends AuthServiceBase { this.password = password; this.photoFolder = photoFolder; - // Connect via NASAPI - const result = await this.nasAPI.connect(host, this.port, username, password, photoFolder); - + await this.nasAPI.connect(host, this.port, username, password, photoFolder); this.isConnected = true; - return { - success: true, - message: `NAS configured: ${host}`, - host: this.host, - username: this.username, - }; + return { success: true, message: `NAS configured: ${host}`, host: this.host, username: this.username }; } catch (error) { console.error('NAS authentication error:', error.message); this.isConnected = false; @@ -55,167 +35,67 @@ class NASAUTH extends AuthServiceBase { } } - /** - * Get current access/connection status - */ async getAccessToken() { - if (!this.isConnected) { - throw new Error('Not connected to NAS'); - } - return { - host: this.host, - username: this.username, - isConnected: this.isConnected, - }; + if (!this.isConnected) throw new Error('Not connected to NAS'); + return { host: this.host, username: this.username, isConnected: this.isConnected }; } - /** - * Refresh connection (reconnect if needed) - */ async refreshToken() { - try { - if (this.host && this.username && this.password) { - return await this.authenticate( - this.host, - this.port, - this.username, - this.password, - this.photoFolder - ); - } - throw new Error('No NAS credentials available for reconnection'); - } catch (error) { - console.error('Connection refresh error:', error); - throw error; + if (this.host && this.username && this.password) { + return await this.authenticate(this.host, this.port, this.username, this.password, this.photoFolder); } + throw new Error('No NAS credentials available for reconnection'); } - /** - * Disconnect from NAS - */ async revokeAccess() { try { await this.nasAPI.disconnect(); - - this.isConnected = false; - this.host = null; - this.username = null; - this.password = null; - this.photoFolder = null; - - return { - success: true, - message: 'NAS connection closed', - }; } catch (error) { console.error('Disconnect error:', error.message); - this.isConnected = false; - throw error; } + this.isConnected = false; + this.host = null; + this.username = null; + this.password = null; + this.photoFolder = null; + return { success: true, message: 'NAS connection closed' }; } - /** - * Get NAS server information - */ async getUserInfo() { - try { - if (!this.isConnected) { - throw new Error('Not connected to NAS'); - } - - return { - host: this.host, - username: this.username, - port: this.port, - photoFolder: this.photoFolder, - connectedAt: new Date().toISOString(), - serverType: 'NAS SFTP', - }; - } catch (error) { - console.error('Get user info error:', error); - throw error; - } + if (!this.isConnected) throw new Error('Not connected to NAS'); + return { + host: this.host, + username: this.username, + port: this.port, + photoFolder: this.photoFolder, + connectedAt: new Date().toISOString(), + serverType: 'NAS SFTP', + }; } - /** - * List directories on NAS - */ async listDirectory(remotePath = '/') { - try { - if (!this.isConnected) { - throw new Error('Not connected to NAS'); - } - - return await this.nasAPI.listDirectory(remotePath); - } catch (error) { - console.error('List directory error:', error); - throw error; - } + if (!this.isConnected) throw new Error('Not connected to NAS'); + return await this.nasAPI.listDirectory(remotePath); } - /** - * Upload file to NAS - */ async uploadFile(localPath, remotePath) { - try { - if (!this.isConnected) { - throw new Error('Not connected to NAS'); - } - - return await this.nasAPI.uploadFile(localPath, remotePath); - } catch (error) { - console.error('Upload error:', error); - throw error; - } + if (!this.isConnected) throw new Error('Not connected to NAS'); + return await this.nasAPI.uploadFile(localPath, remotePath); } - /** - * Get photos from designated NAS photo folder - */ async getPhotos(photoFolder = null) { - try { - if (!this.isConnected) { - throw new Error('Not connected to NAS'); - } - - const folder = photoFolder || this.photoFolder || '/Photos'; - return await this.nasAPI.getPhotos(folder); - } catch (error) { - console.error('Get photos error:', error); - throw error; - } + if (!this.isConnected) throw new Error('Not connected to NAS'); + return await this.nasAPI.getPhotos(photoFolder || this.photoFolder || '/Photos'); } - /** - * Create directory on NAS - */ async createDirectory(remotePath) { - try { - if (!this.isConnected) { - throw new Error('Not connected to NAS'); - } - - return await this.nasAPI.createDirectory(remotePath); - } catch (error) { - console.error('Create directory error:', error); - throw error; - } + if (!this.isConnected) throw new Error('Not connected to NAS'); + return await this.nasAPI.createDirectory(remotePath); } - /** - * Delete file from NAS - */ async deleteFile(remotePath) { - try { - if (!this.isConnected) { - throw new Error('Not connected to NAS'); - } - - return await this.nasAPI.deleteFile(remotePath); - } catch (error) { - console.error('Delete file error:', error); - throw error; - } + if (!this.isConnected) throw new Error('Not connected to NAS'); + return await this.nasAPI.deleteFile(remotePath); } } diff --git a/src/components/MediaViewer.js b/src/components/MediaViewer.js index a0971aa..cdba327 100644 --- a/src/components/MediaViewer.js +++ b/src/components/MediaViewer.js @@ -1,45 +1,202 @@ -import React from 'react'; +import React, { useRef, useState, useEffect, useCallback } from 'react'; import { Modal, View, Text, TouchableOpacity, Image, StyleSheet, + Dimensions, FlatList, } from 'react-native'; import { useTheme } from '../services/ThemeContext'; +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); +const THUMB_SIZE = 60; +const THUMB_GAP = 4; + const getServiceIcon = (type) => ({ GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱'); const getServiceName = (type) => ({ GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type); -const MediaViewer = ({ visible, media, onClose }) => { +/** + * 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 { theme } = useTheme(); + const mainListRef = useRef(null); + const thumbListRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + const [currentMedia, setCurrentMedia] = useState(media); + + const mediaList = allMedia && allMedia.length > 0 ? allMedia : (media ? [media] : []); + const hasCarousel = mediaList.length > 1; + + // When the modal opens or media prop changes, scroll to the correct item + useEffect(() => { + if (!visible || !media || mediaList.length === 0) return; + + const idx = mediaList.findIndex( + (m) => (m.source_id || m.id) === (media.source_id || media.id) + ); + const newIndex = idx >= 0 ? idx : 0; + setActiveIndex(newIndex); + setCurrentMedia(mediaList[newIndex]); + + // Delay scroll so FlatList has rendered + setTimeout(() => { + if (mainListRef.current && newIndex >= 0) { + mainListRef.current.scrollToOffset({ offset: newIndex * SCREEN_WIDTH, animated: false }); + } + if (thumbListRef.current && newIndex >= 0) { + try { + thumbListRef.current.scrollToIndex({ index: newIndex, animated: false, viewPosition: 0.5 }); + } catch (e) { /* index might be out of range during initial render */ } + } + }, 100); + }, [visible, media]); + + const onMainScroll = useCallback( + (event) => { + const offsetX = event.nativeEvent.contentOffset.x; + const idx = Math.round(offsetX / SCREEN_WIDTH); + if (idx >= 0 && idx < mediaList.length && idx !== activeIndex) { + setActiveIndex(idx); + setCurrentMedia(mediaList[idx]); + if (thumbListRef.current) { + try { + thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 }); + } catch (e) { /* ignore */ } + } + } + }, + [activeIndex, mediaList] + ); + + const onThumbPress = (index) => { + setActiveIndex(index); + setCurrentMedia(mediaList[index]); + if (mainListRef.current) { + mainListRef.current.scrollToOffset({ offset: index * SCREEN_WIDTH, animated: true }); + } + }; if (!media) return null; + const renderMainItem = ({ item }) => ( + + {item.local_path ? ( + + ) : ( + + 📷 + + )} + + ); + + const renderThumb = ({ item, index }) => ( + onThumbPress(index)} + activeOpacity={0.8} + > + {item.local_path ? ( + + ) : ( + + 📷 + + )} + + ); + + const displayMedia = currentMedia || media; + return ( + {/* Close button */} ✕ - {media.local_path ? ( - - ) : ( - - 📷 + + {/* Counter */} + {hasCarousel && ( + + + {activeIndex + 1} / {mediaList.length} + )} + + {/* Main image area – swipeable */} + {hasCarousel ? ( + item.source_id || item.id?.toString() || i.toString()} + horizontal + pagingEnabled + showsHorizontalScrollIndicator={false} + onMomentumScrollEnd={onMainScroll} + getItemLayout={(_, index) => ({ + length: SCREEN_WIDTH, + offset: SCREEN_WIDTH * index, + index, + })} + style={styles.mainList} + /> + ) : ( + // Single image mode (backward compat) + displayMedia.local_path ? ( + + ) : ( + + 📷 + + ) + )} + + {/* Thumbnail carousel */} + {hasCarousel && ( + + 'thumb_' + (item.source_id || item.id?.toString() || i)} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.thumbContent} + getItemLayout={(_, index) => ({ + length: THUMB_SIZE + THUMB_GAP, + offset: (THUMB_SIZE + THUMB_GAP) * index, + index, + })} + /> + + )} + + {/* Info panel */} - {media.title || 'Photo'} + {displayMedia.title || 'Photo'} Source - {getServiceIcon(media.source_service)} {getServiceName(media.source_service)} + {getServiceIcon(displayMedia.source_service)} {getServiceName(displayMedia.source_service)} - {media.created_date && ( + {displayMedia.created_date && ( Date - {new Date(media.created_date).toLocaleDateString()} + {new Date(displayMedia.created_date).toLocaleDateString()} )} @@ -58,8 +215,31 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, closeText: { color: '#fff', fontSize: 20, fontWeight: '600' }, + counter: { + position: 'absolute', top: 48, left: 0, right: 0, zIndex: 20, + alignItems: 'center', + }, + counterText: { color: '#fff', fontSize: 14, fontWeight: '600' }, + mainList: { flex: 1 }, fullImage: { flex: 1, width: '100%' }, fullPlaceholder: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + // Thumbnail carousel + thumbCarousel: { + position: 'absolute', bottom: 110, left: 0, right: 0, zIndex: 15, + backgroundColor: 'rgba(0,0,0,0.4)', + paddingVertical: 8, + }, + thumbContent: { paddingHorizontal: 12, gap: THUMB_GAP }, + thumbItem: { + width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: 6, + overflow: 'hidden', borderWidth: 2, borderColor: 'transparent', + }, + thumbImage: { width: '100%', height: '100%' }, + thumbPlaceholder: { + width: '100%', height: '100%', backgroundColor: '#333', + justifyContent: 'center', alignItems: 'center', + }, + // Info panel infoPanel: { padding: 16, paddingBottom: 32, borderTopLeftRadius: 16, borderTopRightRadius: 16, diff --git a/src/components/ServiceHealthIndicator.js b/src/components/ServiceHealthIndicator.js index 34ed1ce..d08c93d 100644 --- a/src/components/ServiceHealthIndicator.js +++ b/src/components/ServiceHealthIndicator.js @@ -1,15 +1,7 @@ import React from 'react'; import { View, StyleSheet } from 'react-native'; -/** - * Circular health indicator dot - * - * status values: - * 'inactive' → grey (not used / not connected) - * 'healthy' → green (connected and working) - * 'error' → red (connected but errors fetching data) - * 'checking' → amber (currently checking) - */ +// Circular health indicator: inactive=grey, healthy=green, error=red, checking=amber const COLORS = { inactive: '#9e9e9e', healthy: '#34a853', diff --git a/src/screens/AlbumsScreen.js b/src/screens/AlbumsScreen.js index 0b57496..e1f0f23 100644 --- a/src/screens/AlbumsScreen.js +++ b/src/screens/AlbumsScreen.js @@ -121,7 +121,7 @@ const CollectionsScreen = ({ route, navigation }) => { contentContainerStyle={styles.gridContent} /> )} - setViewerVisible(false)} /> + setViewerVisible(false)} /> ); } diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index 0636a93..9a771ae 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -187,6 +187,7 @@ const HomeScreen = ({ navigation, route }) => { setViewerVisible(false)} /> diff --git a/src/screens/LoginScreen.js b/src/screens/LoginScreen.js index 35d3600..c42abf1 100644 --- a/src/screens/LoginScreen.js +++ b/src/screens/LoginScreen.js @@ -6,10 +6,7 @@ import { import { useTheme } from '../services/ThemeContext'; import DatabaseService from '../services/DatabaseService'; -// ────────────────────────────────────────────────────────── -// App icon placeholder – change the require path when you -// have a final image in the assets folder. -// ────────────────────────────────────────────────────────── +// App icon — change this path when the final asset is ready const APP_ICON = require('../../assets/icon.png'); const LoginScreen = ({ navigation }) => { diff --git a/src/screens/RegisterScreen.js b/src/screens/RegisterScreen.js index 9c581dc..fd7793f 100644 --- a/src/screens/RegisterScreen.js +++ b/src/screens/RegisterScreen.js @@ -6,7 +6,7 @@ import { import { useTheme } from '../services/ThemeContext'; import DatabaseService from '../services/DatabaseService'; -// Same icon constant as LoginScreen – edit in one place +// App icon — same constant as LoginScreen const APP_ICON = require('../../assets/icon.png'); const RegisterScreen = ({ navigation }) => { diff --git a/src/screens/SearchScreen.js b/src/screens/SearchScreen.js index 28241b2..b434117 100644 --- a/src/screens/SearchScreen.js +++ b/src/screens/SearchScreen.js @@ -92,7 +92,7 @@ const SearchScreen = ({ route }) => { )} - setViewerVisible(false)} /> + setViewerVisible(false)} /> ); }; diff --git a/src/services/CredentialEncryption.js b/src/services/CredentialEncryption.js index 641d44a..c72e022 100644 --- a/src/services/CredentialEncryption.js +++ b/src/services/CredentialEncryption.js @@ -1,24 +1,19 @@ import * as Crypto from 'expo-crypto'; /** - * Credential Encryption Service - * - * Uses expo-crypto for password hashing (SHA-256) - * Uses base64 encoding for storing account data objects in the database + * Handles password hashing (SHA-256) and base64 encoding of account data objects */ class CredentialEncryption { /** - * Hash a password using SHA-256 via expo-crypto - * Returns a hex digest string + * Hash a password with SHA-256 and return a hex digest */ async hashPassword(password) { try { if (!password) throw new Error('Password is required'); - const digest = await Crypto.digestStringAsync( + return await Crypto.digestStringAsync( Crypto.CryptoDigestAlgorithm.SHA256, password ); - return digest; } catch (error) { console.error('Password hashing error:', error); throw new Error('Failed to hash password: ' + error.message); @@ -26,7 +21,7 @@ class CredentialEncryption { } /** - * Verify a password against a stored hash + * Compare a plaintext password against a stored hash */ async verifyPassword(password, storedHash) { try { @@ -39,15 +34,13 @@ class CredentialEncryption { } /** - * Encrypt an object for database storage (base64-encoded JSON) + * Encode an object as a base64 JSON string for database storage */ encrypt(data) { try { if (!data) throw new Error('No data provided to encrypt'); const jsonString = JSON.stringify(data); - // btoa is available in React Native / Hermes - const encoded = btoa(unescape(encodeURIComponent(jsonString))); - return encoded; + return btoa(unescape(encodeURIComponent(jsonString))); } catch (error) { console.error('Encryption error:', error); throw new Error('Failed to encrypt data: ' + error.message); @@ -55,7 +48,7 @@ class CredentialEncryption { } /** - * Decrypt a base64-encoded JSON string back to an object + * Decode a base64 JSON string back to an object */ decrypt(encryptedData) { try { @@ -70,4 +63,3 @@ class CredentialEncryption { } export default new CredentialEncryption(); - diff --git a/src/services/DatabaseService.js b/src/services/DatabaseService.js index a32aff3..fd35380 100644 --- a/src/services/DatabaseService.js +++ b/src/services/DatabaseService.js @@ -5,9 +5,7 @@ let db = null; let initPromise = null; const DatabaseService = { - /** - * Initialize the database and create tables - */ + // Open the database and create all tables initDatabase: async () => { if (initPromise) return initPromise; @@ -63,13 +61,9 @@ const DatabaseService = { ) `); - // Migration: add columns if they don't exist - try { - await db.execAsync(`ALTER TABLE users ADD COLUMN auto_backup_services TEXT DEFAULT '[]'`); - } catch (e) { /* column already exists */ } - try { - await db.execAsync(`ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT 'light'`); - } catch (e) { /* column already exists */ } + // Add optional columns (safe migration — 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 theme_preference TEXT DEFAULT 'light'`); } catch (_) {} console.log('Database initialized successfully'); return true; @@ -89,9 +83,6 @@ const DatabaseService = { if (!db) throw new Error('Database not initialized'); }, - /** - * Create a new user with hashed password - */ createUser: async (username, email, password) => { try { await DatabaseService.ensureDb(); @@ -107,9 +98,7 @@ const DatabaseService = { } }, - /** - * Login user by username or email, verify hashed password - */ + // Authenticate by username or email loginUser: async (identifier, password) => { try { await DatabaseService.ensureDb(); @@ -129,9 +118,6 @@ const DatabaseService = { } }, - /** - * Check if username or email already exists - */ userExists: async (username, email) => { try { await DatabaseService.ensureDb(); @@ -150,34 +136,26 @@ const DatabaseService = { try { await DatabaseService.ensureDb(); if (!userId && userId !== 0) throw new Error('User ID is required'); - const row = await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId]); - return row || null; + return (await db.getFirstAsync('SELECT * FROM users WHERE id = ?', [userId])) || null; } catch (error) { console.error('Error getting user:', error); throw error; } }, - /** - * Link an external service account to a user - */ + // Link an external service account (Google, iCloud, NAS) to a local user linkAccount: async (userId, serviceType, accountData, isDefault = false) => { try { await DatabaseService.ensureDb(); if (!userId) throw new Error('User ID is required'); if (!serviceType) throw new Error('Service type is required'); - if (!accountData || typeof accountData !== 'object') { - throw new Error('Account data must be a valid object'); - } + if (!accountData || typeof accountData !== 'object') throw new Error('Account data must be a valid object'); const encryptedData = CredentialEncryption.encrypt(accountData); if (!encryptedData) throw new Error('Encryption returned empty data'); - // Remove existing account of same type first - await db.runAsync( - 'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?', - [userId, serviceType] - ); + // Replace any existing link for the same service type + await db.runAsync('DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?', [userId, serviceType]); const result = await db.runAsync( 'INSERT INTO linked_accounts (user_id, service_type, account_data, is_default) VALUES (?, ?, ?, ?)', @@ -195,13 +173,9 @@ const DatabaseService = { await DatabaseService.ensureDb(); if (!userId && userId !== 0) throw new Error('User ID is required'); 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) { console.error('Error getting linked accounts:', error); throw error; @@ -238,8 +212,7 @@ const DatabaseService = { try { await DatabaseService.ensureDb(); 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; return { ...row, account_data: CredentialEncryption.decrypt(row.account_data) }; @@ -270,10 +243,7 @@ const DatabaseService = { try { await DatabaseService.ensureDb(); if (!userId && userId !== 0) throw new Error('User ID is required'); - return await db.getAllAsync( - 'SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', - [userId] - ); + return await db.getAllAsync('SELECT * FROM media WHERE user_id = ? ORDER BY created_date DESC', [userId]); } catch (error) { console.error('Error getting media:', error); throw error; @@ -352,14 +322,8 @@ const DatabaseService = { 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), - })); + 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; diff --git a/src/services/ThemeContext.js b/src/services/ThemeContext.js index 53419df..9355137 100644 --- a/src/services/ThemeContext.js +++ b/src/services/ThemeContext.js @@ -3,6 +3,7 @@ import * as SecureStore from 'expo-secure-store'; const THEME_KEY = 'app_theme_preference'; +// Available theme definitions const lightTheme = { name: 'light', background: '#ffffff',