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 CollectionsScreen from './src/screens/AlbumsScreen';
import SettingsScreen from './src/screens/SettingsScreen';
// Required for OAuth deep link redirect to close the browser and return to the app
WebBrowser.maybeCompleteAuthSession();
const Stack = createNativeStackNavigator();
const Tab = createBottomTabNavigator();
// Bottom tab navigator shown after login
function MainTabs({ route }) {
const { userId } = route.params || {};
const { theme } = useTheme();
@@ -73,8 +69,6 @@ function MainTabs({ route }) {
</Tab.Navigator>
);
}
// Root stack navigator
function AppNavigator() {
return (
<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'];
// 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
} catch (_) {}
function runCommand(cmd, args, options = {}) {
return execFileAsync(cmd, args, {
@@ -66,8 +61,6 @@ async function runSftpBatch(conn, batchCommands) {
}
}
// Express setup
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
@@ -82,8 +75,6 @@ app.use((req, res, next) => {
next();
});
// JWT verification middleware
function verifyToken(req, res, next) {
const authHeader = req.headers.authorization;
const token = authHeader?.split(' ')[1];
@@ -100,10 +91,6 @@ function verifyToken(req, res, next) {
}
}
// ─── REST Endpoints ──────────────────────────────────────────────
// Health check
app.get('/api/health', (req, res) => {
res.json({
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) => {
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) {
@@ -141,8 +124,6 @@ app.post('/api/sftp/connect', (req, res) => {
});
});
// List directory contents
app.post('/api/sftp/list', verifyToken, async (req, res) => {
const { path: dirPath = DEFAULT_PHOTO_DIR } = req.body;
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) => {
const { path: filePath } = req.query;
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 {
await runCommand('scp', buildScpArgs(conn, `${conn.username}@${conn.host}:${filePath}`, tempFilePath));
res.download(tempFilePath, filename, () => {
try { fs.unlinkSync(tempFilePath); } catch (_) { /* already cleaned */ }
try { fs.unlinkSync(tempFilePath); } catch (_) {}
});
} catch (err) {
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) => {
const { remotePath } = req.body;
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) => {
const { path: dirPath } = req.body;
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) => {
const { path: filePath } = req.body;
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) => {
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) {
@@ -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) => {
const { googleAccessToken, baseUrl, filename, remotePath } = req.body;
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) => {
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));
-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 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');
// Test 1 — User info
console.log('[1] Fetching user info...');
try {
const res = await fetch(USERINFO_URL, { headers });
@@ -51,7 +32,6 @@ async function main() {
console.log(` Failed: ${err.message}\n`);
}
// Test 2 — Create Picker session
console.log('[2] Creating Picker session...');
let sessionId = null;
try {
@@ -68,7 +48,6 @@ async function main() {
console.log(` Session created: ${sessionId}`);
console.log(` mediaItemsSet: ${session.mediaItemsSet}\n`);
// Wait for session to be ready
if (!session.mediaItemsSet) {
console.log(' Waiting for session to become ready...');
for (let i = 0; i < 10; i++) {
@@ -87,7 +66,6 @@ async function main() {
console.log(` Failed: ${err.message}\n`);
}
// Test 3 — List media items from session
if (sessionId) {
console.log('[3] Fetching media items from session...');
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';
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`);
@@ -50,7 +28,6 @@ async function main() {
return;
}
// 2 — Connect
let connectionToken = null;
console.log(`[2] SFTP connect to ${host}:${port}...`);
try {
@@ -74,7 +51,6 @@ async function main() {
Authorization: `Bearer ${connectionToken}`,
};
// 3 — List directory
console.log(`[3] List directory "${folder}"...`);
try {
const res = await fetch(`${BACKEND_URL}/api/sftp/list`, {
@@ -93,7 +69,6 @@ async function main() {
console.log(` Failed: ${err.message}\n`);
}
// 4 — Disconnect
console.log('[4] Disconnect...');
try {
const res = await fetch(`${BACKEND_URL}/api/sftp/disconnect`, {
+1
View File
@@ -1,5 +1,6 @@
{
"expo": {
"entryPoint": "./App.js",
"name": "MASS Photo App",
"slug": "mass-expo",
"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",
"version": "1.0.0",
"main": "index.js",
"main": "App.js",
"scripts": {
"start": "expo start",
"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 PICKER_ROOT = 'https://photospicker.googleapis.com/v1';
@@ -29,13 +27,10 @@ const GooglePhotosAPI = (getAccessToken) => {
};
return {
// Create a Picker session — must be called before listing items
createSession: () => callFetch(`${PICKER_ROOT}/sessions`, 'POST', {}),
// Poll session status
getSession: (sessionId) => callFetch(`${PICKER_ROOT}/sessions/${sessionId}`, 'GET'),
// List media items from a ready session
listMediaItems: (sessionId, pageSize = 25, pageToken) => {
const params = new URLSearchParams({ sessionId, pageSize: String(pageSize) });
if (pageToken) params.append('pageToken', pageToken);
-1
View File
@@ -1,4 +1,3 @@
// 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' }),
+1 -4
View File
@@ -1,6 +1,5 @@
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';
class NASAPI {
@@ -11,7 +10,6 @@ class NASAPI {
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') {
if (!host || !username || !password) throw new Error('Host, username, and password are required');
@@ -50,7 +48,6 @@ class NASAPI {
return response.data;
}
// 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' };
@@ -120,7 +117,7 @@ class NASAPI {
if (this.connectionToken) {
try {
await axios.post(`${this.backendUrl}/api/sftp/disconnect`, {}, { headers: this.getHeaders(), timeout: 5000 });
} catch (_) { /* ignore */ }
} catch (_) {}
}
this.connectionToken = null;
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 {
constructor() {
if (new.target === AuthServiceBase) {
+3 -75
View File
@@ -7,11 +7,8 @@ import { logError } from '../services/ErrorHandler';
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 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 = {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
@@ -19,9 +16,6 @@ const GOOGLE_DISCOVERY = {
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 = [
'openid',
'https://www.googleapis.com/auth/userinfo.email',
@@ -44,8 +38,6 @@ class GoogleAUTH extends AuthServiceBase {
this.email = null;
}
// ───────────────────── Authentication ─────────────────────
async authenticate() {
if (ANDROID_CLIENT_ID === 'REPLACE_WITH_ANDROID_CLIENT_ID.apps.googleusercontent.com') {
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' };
}
// ───────────────── 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() {
const token = await this.getAccessToken();
const res = await fetch(`${PICKER_API}/sessions`, {
@@ -225,10 +200,6 @@ class GoogleAUTH extends AuthServiceBase {
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) {
const token = await this.getAccessToken();
const maxAttempts = 20;
@@ -254,9 +225,6 @@ class GoogleAUTH extends AuthServiceBase {
return null;
}
/**
* Fetch media items from a ready Picker session.
*/
async _fetchPickerMediaItems(sessionId, pageSize = 100, pageToken = null) {
const token = await this.getAccessToken();
const params = new URLSearchParams({
@@ -277,7 +245,6 @@ class GoogleAUTH extends AuthServiceBase {
const data = await res.json();
// Debug: log raw response structure
if (data.mediaItems?.length) {
console.log('Picker raw item[0]:', JSON.stringify(data.mediaItems[0], null, 2));
} else {
@@ -287,19 +254,10 @@ class GoogleAUTH extends AuthServiceBase {
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) {
if (!items?.length) return [];
return items.map((item, idx) => {
// The item may have mediaFile nested, or may be flat
const file = item.mediaFile || item;
const baseUrl = file.baseUrl || item.baseUrl || null;
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) {
const token = await this.getAccessToken();
// Ensure cache directory exists
if (!CACHE_DIR.exists) {
CACHE_DIR.create({ intermediates: true });
} else {
// Clean up stale files from previous failed downloads (< 1KB = likely error pages)
try {
const entries = CACHE_DIR.list();
for (const entry of entries) {
@@ -353,7 +299,7 @@ class GoogleAUTH extends AuthServiceBase {
entry.delete();
}
}
} catch (_) { /* ignore cleanup errors */ }
} catch (_) {}
}
const results = [];
@@ -371,14 +317,11 @@ class GoogleAUTH extends AuthServiceBase {
: '.jpg';
const destFile = new File(CACHE_DIR, `${safeId}${ext}`);
// Skip download if already cached and non-empty
if (destFile.exists && destFile.size > 1024) {
results.push({ ...item, local_path: destFile.uri });
continue;
}
// The Picker API baseUrl is a direct download link when used with
// the Authorization header. No suffix needed.
console.log(`Downloading: ${item.title} (${item.picker_base_url.substring(0, 80)}…)`);
const downloaded = await File.downloadFileAsync(
@@ -392,19 +335,17 @@ class GoogleAUTH extends AuthServiceBase {
if (downloaded.exists && downloaded.size > 1024) {
results.push({ ...item, local_path: downloaded.uri });
} else if (downloaded.exists && downloaded.size > 0) {
// Suspiciously small — check if it's an error page
try {
const peek = await downloaded.text();
if (peek.startsWith('<') || peek.startsWith('{')) {
logError('Download Picker Photo',
`Got error page for ${item.title}: ${peek.substring(0, 150)}`);
downloaded.delete();
results.push(item); // no local_path
results.push(item);
} else {
results.push({ ...item, local_path: downloaded.uri });
}
} catch (_) {
// Can't read as text = probably binary = probably an image
results.push({ ...item, local_path: downloaded.uri });
}
} else {
@@ -421,15 +362,8 @@ class GoogleAUTH extends AuthServiceBase {
return results;
}
/**
* Full interactive flow: create session → open picker in browser →
* wait for user to select photos → fetch the selected items.
*
* @returns {{ mediaItems, nextPageToken, sessionId, error? }}
*/
async pickPhotos() {
try {
// 1. Create session
console.log('Creating Google Photos Picker session…');
const session = await this._createPickerSession();
@@ -449,7 +383,6 @@ class GoogleAUTH extends AuthServiceBase {
console.log('Picker session created:', session.id);
console.log('Opening picker for user selection…');
// 2. Open the picker URI in a browser for the user to select photos
await WebBrowser.openBrowserAsync(session.pickerUri, {
dismissButtonStyle: 'done',
presentationStyle: 'pageSheet',
@@ -457,7 +390,6 @@ class GoogleAUTH extends AuthServiceBase {
console.log('Browser closed — polling for selected photos…');
// 3. Poll until the user's selections are ready
const ready = await this._waitForSession(session.id);
if (!ready) {
return {
@@ -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);
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) {
try {
if (!sessionId || !pageToken) {
-1
View File
@@ -1,6 +1,5 @@
import { AuthServiceBase } from './AuthServiceBase';
// iCloud Photos placeholder — Apple does not provide a public iCloud Photos API
class IOSAUTH extends AuthServiceBase {
constructor() {
super();
-1
View File
@@ -1,7 +1,6 @@
import { AuthServiceBase } from './AuthServiceBase';
import NASAPI from '../API/NASAPI';
// NAS SFTP authentication — connects to the backend server via NASAPI
class NASAUTH extends AuthServiceBase {
constructor() {
super();
+2 -22
View File
@@ -13,16 +13,6 @@ const getServiceIcon = (type) =>
({ DEVICE: '📱', GOOGLE_PHOTOS: '📷', ICLOUD_PHOTOS: '🍎', NAS: '💾' }[type] || '📱');
const getServiceName = (type) =>
({ DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' }[type] || type);
/**
* 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);
@@ -33,7 +23,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
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;
@@ -44,7 +33,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
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 });
@@ -52,7 +40,7 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
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 */ }
} catch (e) {}
}
}, 100);
}, [visible, media]);
@@ -69,7 +57,7 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
if (thumbListRef.current) {
try {
thumbListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
} catch (_) { /* ignore */ }
} catch (_) {}
}
}
};
@@ -120,12 +108,10 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<View style={[styles.container, { backgroundColor: theme.modalBg }]}>
{/* Close button */}
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Text style={styles.closeText}>✕</Text>
</TouchableOpacity>
{/* Counter */}
{hasCarousel && (
<View style={styles.counter}>
<Text style={styles.counterText}>
@@ -134,7 +120,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
</View>
)}
{/* Main image area – swipeable */}
{hasCarousel ? (
<FlatList
ref={mainListRef}
@@ -153,7 +138,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
style={styles.mainList}
/>
) : (
// Single image mode (backward compat)
displayMedia.local_path ? (
<Image source={{ uri: displayMedia.local_path }} style={styles.fullImage} resizeMode="contain" />
) : (
@@ -163,7 +147,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
)
)}
{/* Thumbnail carousel */}
{hasCarousel && (
<View style={styles.thumbCarousel}>
<FlatList
@@ -183,7 +166,6 @@ const MediaViewer = ({ visible, media, allMedia, onClose }) => {
</View>
)}
{/* Info panel */}
<View style={[styles.infoPanel, { backgroundColor: theme.modalInfoBg }]}>
<Text style={styles.title}>{displayMedia.title || 'Photo'}</Text>
<View style={styles.row}>
@@ -234,7 +216,6 @@ const styles = StyleSheet.create({
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)',
@@ -250,7 +231,6 @@ const styles = StyleSheet.create({
width: '100%', height: '100%', backgroundColor: '#333',
justifyContent: 'center', alignItems: 'center',
},
// Info panel
infoPanel: {
padding: 16, paddingBottom: 32,
borderTopLeftRadius: 16, borderTopRightRadius: 16,
-2
View File
@@ -29,7 +29,6 @@ const PhotoGrid = ({
: [item.source_service];
if (sources.length === 1) {
// Single source — simple badge
return (
<View style={styles.sourceBadge}>
<Text style={{ fontSize: 10 }}>{getServiceIcon(sources[0])}</Text>
@@ -37,7 +36,6 @@ const PhotoGrid = ({
);
}
// Multiple sources — show all icons with a subtle indicator
return (
<View style={styles.multiSourceBadge}>
{sources.map((src, i) => (
-1
View File
@@ -1,7 +1,6 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
// Circular health indicator: inactive=grey, healthy=green, error=red, checking=amber
const COLORS = {
inactive: '#9e9e9e',
healthy: '#34a853',
-7
View File
@@ -39,7 +39,6 @@ const CollectionsScreen = ({ route }) => {
const allMedia = await DatabaseService.getMediaByUser(userId);
const sectionList = [];
// ─── Utility albums (top) ───
const utilityAlbums = [
{ name: 'All Photos', icon: '📸', count: allMedia.length, media: allMedia, type: 'utility' },
{ name: 'Hidden', icon: '🙈', count: 0, media: [], type: 'utility' },
@@ -48,7 +47,6 @@ const CollectionsScreen = ({ route }) => {
];
sectionList.push({ title: '', data: [{ albums: utilityAlbums }] });
// ─── Per-service categories ───
for (const acc of accounts) {
const media = await DatabaseService.getMediaByService(userId, acc.service_type);
const serviceAlbums = [
@@ -77,7 +75,6 @@ const CollectionsScreen = ({ route }) => {
}
};
// Album detail view
if (selectedAlbum) {
const gridItemSize = (width - 6) / 3;
return (
@@ -209,13 +206,11 @@ const styles = StyleSheet.create({
header: { paddingHorizontal: 16, paddingTop: 52, paddingBottom: 12 },
headerTitle: { fontSize: 24, fontWeight: '700' },
listContent: { paddingHorizontal: ALBUM_GAP, paddingBottom: 20 },
// Section headers
sectionHeader: {
flexDirection: 'row', alignItems: 'center', paddingVertical: 12, gap: 8,
},
sectionIcon: { fontSize: 20 },
sectionTitle: { fontSize: 16, fontWeight: '700' },
// Album grid
albumRow: { flexDirection: 'row', gap: ALBUM_GAP, marginBottom: ALBUM_GAP },
albumCard: {},
albumCover: { borderRadius: 12, overflow: 'hidden', marginBottom: 8 },
@@ -225,7 +220,6 @@ const styles = StyleSheet.create({
},
albumName: { fontSize: 14, fontWeight: '600', paddingHorizontal: 2 },
albumCount: { fontSize: 12, paddingHorizontal: 2, marginTop: 2 },
// Album detail
albumHeader: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16,
paddingTop: 52, paddingBottom: 12, gap: 12, borderBottomWidth: 1,
@@ -238,7 +232,6 @@ const styles = StyleSheet.create({
gridItem: { margin: 1 },
gridImage: { width: '100%', height: '100%' },
gridPlaceholder: { width: '100%', height: '100%', justifyContent: 'center', alignItems: 'center' },
// Empty / Loading
loadingText: { marginTop: 12, fontSize: 14 },
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center' },
-33
View File
@@ -14,16 +14,7 @@ import PhotoGrid from '../components/PhotoGrid';
import MediaViewer from '../components/MediaViewer';
import { logError } from '../services/ErrorHandler';
import { TouchableOpacity } from 'react-native';
/**
* Merge media items from different services.
* Items with the same filename (title) are merged into a single entry
* with a `sources` array listing every service it appears in.
* The best local_path is kept (prefer file:// from DEVICE over a cached
* Picker download, since device URIs are always fresh).
*/
const mergeMediaAcrossServices = (items) => {
// First pass: group by normalised filename
const byFilename = new Map();
const noFilename = [];
@@ -36,15 +27,12 @@ const mergeMediaAcrossServices = (items) => {
if (byFilename.has(key)) {
const existing = byFilename.get(key);
// Add this service to the sources list
if (!existing.sources.includes(item.source_service)) {
existing.sources.push(item.source_service);
}
// Prefer a local device path over a cached download
if (item.source_service === 'DEVICE' && item.local_path) {
existing.local_path = item.local_path;
}
// Keep the best local_path if the existing one is missing
if (!existing.local_path && item.local_path) {
existing.local_path = item.local_path;
}
@@ -52,8 +40,6 @@ const mergeMediaAcrossServices = (items) => {
byFilename.set(key, { ...item, sources: [item.source_service] });
}
}
// Second pass: also deduplicate by source_id for items with different titles
const bySourceId = new Map();
for (const item of [...byFilename.values(), ...noFilename]) {
const key = item.source_id || item.id || item.local_path || Math.random().toString();
@@ -118,7 +104,6 @@ const HomeScreen = ({ navigation, route }) => {
try {
const newMedia = [];
// ─── Device photos (local camera roll, screenshots, etc.) ───
try {
const deviceResult = await DeviceMediaService.getPhotos(100);
if (deviceResult.mediaItems.length) {
@@ -130,11 +115,9 @@ const HomeScreen = ({ navigation, route }) => {
console.error('Device media fetch error:', err);
}
// ─── Previously imported Google Photos (stored in DB) ───
try {
const googleMedia = await DatabaseService.getMediaByService(userId, 'GOOGLE_PHOTOS');
if (googleMedia?.length) {
// Validate cached files still exist — clear stale paths
const validated = googleMedia.map((item) => {
if (item.local_path) {
try {
@@ -154,7 +137,6 @@ const HomeScreen = ({ navigation, route }) => {
console.error('DB Google media fetch error:', err);
}
// ─── NAS fetch ───
const nasAccount = linkedAccounts.find((a) => a.service_type === 'NAS');
if (nasAccount) {
try {
@@ -169,15 +151,11 @@ const HomeScreen = ({ navigation, route }) => {
}
}
// ─── iCloud (placeholder) ───
const iosAccount = linkedAccounts.find((a) => a.service_type === 'ICLOUD_PHOTOS');
if (iosAccount) {
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);
setAllMedia(merged);
} catch (err) {
@@ -188,7 +166,6 @@ const HomeScreen = ({ navigation, route }) => {
}
};
// ─── Import from Google Photos (opens picker in browser) ───
const importGooglePhotos = async () => {
const googleAccount = linkedAccounts.find((a) => a.service_type === 'GOOGLE_PHOTOS');
if (!googleAccount) {
@@ -206,7 +183,6 @@ const HomeScreen = ({ navigation, route }) => {
let result = await googleAuth.pickPhotos();
// If 403 (stale scopes), auto re-auth and retry
if (result?.error?.code === 403) {
console.log('Stale token — re-authenticating…');
await googleAuth.revokeAccess();
@@ -223,7 +199,6 @@ const HomeScreen = ({ navigation, route }) => {
if (result?.error) {
logError('Import Google Photos', result.error.message);
} else if (result?.mediaItems?.length) {
// Save imported items to DB so they persist across app restarts
for (const item of result.mediaItems) {
await DatabaseService.saveMediaItem(userId, item);
}
@@ -231,13 +206,11 @@ const HomeScreen = ({ navigation, route }) => {
setGoogleNextPage(result.nextPageToken || null);
setGoogleSessionId(result.sessionId || null);
// Merge into current view with cross-service matching
setAllMedia((prev) => mergeMediaAcrossServices([...prev, ...result.mediaItems]));
console.log(`Imported ${result.mediaItems.length} photos from Google`);
}
// Persist refreshed tokens
if (googleAuth.accessToken && googleAuth.accessToken !== data.accessToken) {
await DatabaseService.linkAccount(userId, 'GOOGLE_PHOTOS', {
accessToken: googleAuth.accessToken,
@@ -256,7 +229,6 @@ const HomeScreen = ({ navigation, route }) => {
const filterMedia = () => {
let filtered = allMedia;
if (selectedFilter !== 'all') {
// Check both source_service and the sources array for cross-service items
filtered = allMedia.filter((m) =>
m.source_service === selectedFilter ||
(m.sources && m.sources.includes(selectedFilter))
@@ -328,7 +300,6 @@ const HomeScreen = ({ navigation, route }) => {
);
}
// Build the list of available source filters
const sourceFilters = [
{ key: 'DEVICE', icon: '📱', name: 'On Device' },
...linkedAccounts.map((acc) => ({
@@ -372,7 +343,6 @@ const HomeScreen = ({ navigation, route }) => {
<FilterChips />
{/* Import from Google Photos button */}
{hasGoogleAccount && (
<TouchableOpacity
style={[styles.importButton, { backgroundColor: theme.primary }]}
@@ -437,7 +407,6 @@ const styles = StyleSheet.create({
container: { flex: 1 },
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24 },
loadingText: { marginTop: 12, fontSize: 14 },
// Filter chips
filterContainer: { maxHeight: 40 },
filterRow: { paddingHorizontal: 12, paddingVertical: 6, gap: 6, alignItems: 'center' },
chip: {
@@ -446,7 +415,6 @@ const styles = StyleSheet.create({
},
chipIcon: { fontSize: 11 },
chipText: { fontSize: 12, fontWeight: '500', color: '#3c4043' },
// Import button
importButton: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
marginHorizontal: 12, marginVertical: 6, paddingVertical: 10, paddingHorizontal: 16,
@@ -454,7 +422,6 @@ const styles = StyleSheet.create({
},
importIcon: { fontSize: 16 },
importText: { color: '#fff', fontSize: 14, fontWeight: '600' },
// Empty
emptyTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
emptyText: { fontSize: 13, textAlign: 'center', marginBottom: 20, lineHeight: 20 },
retryButton: { paddingHorizontal: 24, paddingVertical: 10, borderRadius: 20 },
-2
View File
@@ -84,7 +84,6 @@ const LoginScreen = ({ navigation }) => {
</TouchableOpacity>
</View>
{/* Login button – blue */}
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]}
onPress={handleLogin}
@@ -97,7 +96,6 @@ const LoginScreen = ({ navigation }) => {
)}
</TouchableOpacity>
{/* Register button – white/outlined */}
<TouchableOpacity
style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]}
onPress={() => navigation.navigate('Register')}
-15
View File
@@ -16,29 +16,24 @@ const OnboardingScreen = ({ navigation, route }) => {
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
// Service toggles
const [googleEnabled, setGoogleEnabled] = useState(false);
const [iosEnabled, setIosEnabled] = useState(false);
const [nasEnabled, setNasEnabled] = useState(false);
// Service connection status
const [googleConnected, setGoogleConnected] = useState(false);
const [iosConnected, setIosConnected] = useState(false);
const [nasConnected, setNasConnected] = useState(false);
// Service health: 'inactive' | 'healthy' | 'error' | 'checking'
const [googleHealth, setGoogleHealth] = useState('inactive');
const [iosHealth, setIosHealth] = useState('inactive');
const [nasHealth, setNasHealth] = useState('inactive');
// NAS config
const [nasHost, setNasHost] = useState('');
const [nasPort, setNasPort] = useState('22');
const [nasUsername, setNasUsername] = useState('');
const [nasPassword, setNasPassword] = useState('');
const [nasPhotoFolder, setNasPhotoFolder] = useState('/home/user/photos');
// Auto-backup checklist (multi-select)
const [autoBackupServices, setAutoBackupServices] = useState([]);
const steps = [
@@ -165,7 +160,6 @@ const OnboardingScreen = ({ navigation, route }) => {
return (
<View style={[styles.container, { backgroundColor: theme.background }]}>
{/* Progress bar */}
<View style={styles.progressBar}>
{steps.map((_, i) => (
<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>
</View>
{/* Step 0: Select services */}
{currentStep === 0 && (
<View style={styles.cardsContainer}>
<ServiceToggleCard theme={theme} icon="📷" name="Google Photos"
@@ -192,12 +185,10 @@ const OnboardingScreen = ({ navigation, route }) => {
description="Connect to your Ubuntu server via SFTP"
enabled={nasEnabled} onToggle={() => setNasEnabled(!nasEnabled)} />
{/* NAS setup instructions dropdown */}
{nasEnabled && <NASSetupInstructions />}
</View>
)}
{/* Step 1: Configure each service */}
{currentStep === 1 && (
<View style={styles.cardsContainer}>
{googleEnabled && (
@@ -281,7 +272,6 @@ const OnboardingScreen = ({ navigation, route }) => {
</View>
)}
{/* Step 2: Auto-backup multi-checkbox */}
{currentStep === 2 && (
<View style={styles.cardsContainer}>
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.border }]}>
@@ -327,7 +317,6 @@ const OnboardingScreen = ({ navigation, route }) => {
)}
</ScrollView>
{/* Footer */}
<View style={[styles.footer, { borderTopColor: theme.border }]}>
{currentStep > 0 ? (
<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 },
description: { fontSize: 14 },
cardsContainer: { gap: 12 },
// Toggle cards (Step 0)
toggleCard: {
borderRadius: 12, padding: 16, flexDirection: 'row',
justifyContent: 'space-between', alignItems: 'center', borderWidth: 1.5,
@@ -393,7 +381,6 @@ const styles = StyleSheet.create({
toggleInfo: { flex: 1 },
toggleName: { fontSize: 15, fontWeight: '600', marginBottom: 2 },
toggleDescription: { fontSize: 12 },
// Config cards (Step 1)
configCard: { borderRadius: 12, padding: 16, borderWidth: 1 },
configHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 },
configIcon: { fontSize: 24, marginRight: 10 },
@@ -405,7 +392,6 @@ const styles = StyleSheet.create({
fieldInput: {
borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14,
},
// Auto-backup (Step 2)
checklistInfo: { fontSize: 13, marginBottom: 8 },
checkRow: {
flexDirection: 'row', alignItems: 'center', paddingVertical: 14,
@@ -418,7 +404,6 @@ const styles = StyleSheet.create({
checkmark: { color: '#fff', fontSize: 14, fontWeight: '700' },
checkIcon: { fontSize: 18, marginRight: 8 },
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
// Footer
footer: {
flexDirection: 'row', paddingHorizontal: 20, paddingVertical: 14,
borderTopWidth: 1, gap: 12,
-2
View File
@@ -90,7 +90,6 @@ const RegisterScreen = ({ navigation }) => {
/>
</View>
{/* Create Account button – blue */}
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.primary, borderColor: theme.border }, loading && styles.buttonDisabled]}
onPress={handleRegister}
@@ -101,7 +100,6 @@ const RegisterScreen = ({ navigation }) => {
)}
</TouchableOpacity>
{/* Back to Login button – white/outlined */}
<TouchableOpacity
style={[styles.secondaryButton, { backgroundColor: theme.background, borderColor: theme.border }]}
onPress={() => navigation.navigate('Login')}
+1 -25
View File
@@ -18,11 +18,7 @@ const SettingsScreen = ({ navigation, route }) => {
const [accounts, setAccounts] = useState([]);
const [autoBackup, setAutoBackup] = useState([]);
const [nasOffload, setNasOffload] = useState(false);
// Per-service health: { GOOGLE_PHOTOS: 'healthy'|'error'|'inactive', ... }
const [serviceHealth, setServiceHealth] = useState({});
// NAS edit fields
const [editingNas, setEditingNas] = useState(false);
const [nasHost, setNasHost] = useState('');
const [nasPort, setNasPort] = useState('22');
@@ -51,14 +47,12 @@ const SettingsScreen = ({ navigation, route }) => {
setNasPhotoFolder(nasAcc.account_data.photoFolder || '/home/user/photos');
}
// Check health for each enabled account
checkAllServiceHealth(allAccounts);
} catch (err) {
console.error('Error loading settings:', err);
}
};
// ─── Health checks ─────────────────────────────────────────
const checkAllServiceHealth = async (accs) => {
const health = {};
for (const acc of accs) {
@@ -76,8 +70,6 @@ const SettingsScreen = ({ navigation, route }) => {
const checkSingleHealth = async (acc) => {
try {
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();
gAuth.accessToken = acc.account_data?.accessToken;
gAuth.refreshToken = acc.account_data?.refreshToken;
@@ -93,7 +85,6 @@ const SettingsScreen = ({ navigation, route }) => {
return 'healthy';
}
if (acc.service_type === 'ICLOUD_PHOTOS') {
// Placeholder – always "healthy" since it's a stub
return 'healthy';
}
return 'inactive';
@@ -108,7 +99,6 @@ const SettingsScreen = ({ navigation, route }) => {
setServiceHealth((prev) => ({ ...prev, [acc.service_type]: status }));
};
// ─── Service actions ───────────────────────────────────────
const toggleService = async (accountId, currentEnabled) => {
try {
await DatabaseService.toggleAccountEnabled(accountId, !currentEnabled);
@@ -168,7 +158,7 @@ const SettingsScreen = ({ navigation, route }) => {
} else if (acc.service_type === 'NAS') {
setEditingNas(true);
setLoading(false);
return; // user edits inline
return;
}
await loadData();
} catch (error) {
@@ -249,7 +239,6 @@ const SettingsScreen = ({ navigation, route }) => {
</View>
<ScrollView style={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* ── Theme ──────────────────────────────────────── */}
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>APPEARANCE</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
{themeOptions.map((opt) => (
@@ -272,7 +261,6 @@ const SettingsScreen = ({ navigation, route }) => {
))}
</View>
{/* ── Services ──────────────────────────────────── */}
<Text style={[styles.sectionTitle, { color: theme.textSecondary }]}>SERVICES</Text>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
{accounts.length === 0 ? (
@@ -286,7 +274,6 @@ const SettingsScreen = ({ navigation, route }) => {
const health = serviceHealth[acc.service_type] || (acc.is_enabled ? 'checking' : 'inactive');
return (
<View key={acc.id}>
{/* Main row */}
<View style={[styles.serviceRow, { borderBottomColor: theme.borderLight }]}>
<TouchableOpacity onPress={() => recheckHealth(acc)} activeOpacity={0.6}>
<ServiceHealthIndicator status={health} size={10} />
@@ -307,7 +294,6 @@ const SettingsScreen = ({ navigation, route }) => {
thumbColor={acc.is_enabled ? theme.accent : '#f4f3f4'}
/>
</View>
{/* Action buttons row */}
<View style={[styles.serviceActions, { borderBottomColor: theme.borderLight }]}>
{acc.service_type === 'NAS' && acc.is_enabled && (
<TouchableOpacity
@@ -339,7 +325,6 @@ const SettingsScreen = ({ navigation, route }) => {
)}
</View>
{/* NAS Edit Config */}
{editingNas && (
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.cardTitle, { color: theme.text }]}>Edit NAS Configuration</Text>
@@ -364,10 +349,8 @@ const SettingsScreen = ({ navigation, route }) => {
</View>
)}
{/* NAS Setup Instructions */}
<NASSetupInstructions />
{/* NAS Google Photos Offloading */}
{hasNasConnected && (
<>
<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>
<View style={[styles.card, { backgroundColor: theme.card, borderColor: theme.border }]}>
<Text style={[styles.cardSubtext, { color: theme.textSecondary }]}>
@@ -452,7 +434,6 @@ const SettingsScreen = ({ navigation, route }) => {
</TouchableOpacity>
</View>
{/* ── Sign Out ──────────────────────────────────── */}
<TouchableOpacity
style={[styles.logoutButton, { borderColor: theme.danger }]}
onPress={handleLogout}
@@ -482,7 +463,6 @@ const styles = StyleSheet.create({
},
cardTitle: { fontSize: 15, fontWeight: '600', padding: 14, paddingBottom: 4 },
cardSubtext: { fontSize: 12, paddingHorizontal: 14, paddingTop: 10, paddingBottom: 4 },
// Theme rows
themeRow: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
@@ -494,7 +474,6 @@ const styles = StyleSheet.create({
radio: {
width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: '#dadce0',
},
// Service rows
serviceRow: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
@@ -513,7 +492,6 @@ const styles = StyleSheet.create({
actionBtnText: { fontSize: 12, fontWeight: '600' },
emptyRow: { padding: 16 },
emptyText: { fontSize: 13, textAlign: 'center' },
// NAS fields
fieldLabel: { fontSize: 12, fontWeight: '600', marginTop: 10, marginBottom: 4, paddingHorizontal: 14 },
fieldInput: {
borderWidth: 1, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 10,
@@ -523,7 +501,6 @@ const styles = StyleSheet.create({
borderRadius: 8, paddingVertical: 12, alignItems: 'center', margin: 14,
},
saveButtonText: { color: '#fff', fontSize: 14, fontWeight: '600' },
// Auto backup checkbox rows
checkRow: {
flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
@@ -536,7 +513,6 @@ const styles = StyleSheet.create({
checkIcon: { fontSize: 18, marginRight: 8 },
checkLabel: { fontSize: 14, fontWeight: '500', flex: 1 },
notConnected: { fontSize: 11 },
// Logout
logoutButton: {
borderWidth: 1.5, borderRadius: 8, paddingVertical: 14,
alignItems: 'center', marginTop: 24,
-15
View File
@@ -1,12 +1,6 @@
import * as Crypto from 'expo-crypto';
/**
* Handles password hashing (SHA-256) and base64 encoding of account data objects
*/
class CredentialEncryption {
/**
* Hash a password with SHA-256 and return a hex digest
*/
async hashPassword(password) {
try {
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) {
try {
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) {
try {
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) {
try {
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 CredentialEncryption from './CredentialEncryption';
import { logError } from './ErrorHandler';
@@ -12,8 +6,6 @@ let db = null;
let initPromise = null;
const DatabaseService = {
// ─── Initialisation ────────────────────────────────────────
initDatabase: async () => {
if (initPromise) return initPromise;
@@ -32,7 +24,6 @@ const DatabaseService = {
return false;
}
// Create all tables in one batch
await db.execAsync(`
CREATE TABLE IF NOT EXISTS users (
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 theme_preference TEXT DEFAULT 'light'"); } catch (_) {}
@@ -94,8 +84,6 @@ const DatabaseService = {
return true;
},
// ─── Users ─────────────────────────────────────────────────
createUser: async (username, email, password) => {
try {
if (!(await DatabaseService.ensureDb())) return null;
@@ -155,8 +143,6 @@ const DatabaseService = {
}
},
// ─── Linked Accounts ──────────────────────────────────────
linkAccount: async (userId, serviceType, accountData, isDefault = false) => {
try {
if (!(await DatabaseService.ensureDb())) return null;
@@ -171,7 +157,6 @@ const DatabaseService = {
return null;
}
// Replace any existing link for the same service type
await db.runAsync(
'DELETE FROM linked_accounts WHERE user_id = ? AND service_type = ?',
[userId, serviceType]
@@ -286,8 +271,6 @@ const DatabaseService = {
}
},
// ─── Media ─────────────────────────────────────────────────
addMedia: async (userId, sourceService, sourceId, title, description, localPath, remotePath, mediaType, createdDate) => {
try {
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) => {
try {
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) {
const existing = await db.getFirstAsync(
'SELECT id FROM media WHERE user_id = ? AND source_id = ?',
@@ -384,8 +363,6 @@ const DatabaseService = {
}
},
// ─── Settings ──────────────────────────────────────────────
getAutoBackupServices: async (userId) => {
try {
if (!(await DatabaseService.ensureDb())) return [];
@@ -413,8 +390,6 @@ const DatabaseService = {
}
},
// ─── Lifecycle ─────────────────────────────────────────────
closeDatabase: async () => {
try {
if (db?.closeAsync) await db.closeAsync();
+1 -27
View File
@@ -1,18 +1,6 @@
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 = {
/**
* Request media-library permission (camera roll / storage).
* Returns true if granted.
*/
requestPermission: async () => {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
@@ -22,14 +10,6 @@ const DeviceMediaService = {
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') => {
const hasPermission = await DeviceMediaService.requestPermission();
if (!hasPermission) {
@@ -44,7 +24,7 @@ const DeviceMediaService = {
const options = {
first: pageSize,
mediaType: mediaTypeFilter,
sortBy: [[MediaLibrary.SortBy.creationTime, false]], // newest first
sortBy: [[MediaLibrary.SortBy.creationTime, false]],
};
if (after) options.after = after;
@@ -73,9 +53,6 @@ const DeviceMediaService = {
};
},
/**
* Fetch all albums from the device (including smart albums).
*/
getAlbums: async () => {
const hasPermission = await DeviceMediaService.requestPermission();
if (!hasPermission) return [];
@@ -91,9 +68,6 @@ const DeviceMediaService = {
}));
},
/**
* Fetch assets inside a specific album.
*/
getAlbumPhotos: async (albumId, pageSize = 100, after = null) => {
const hasPermission = await DeviceMediaService.requestPermission();
if (!hasPermission) {
-21
View File
@@ -1,31 +1,10 @@
import { Alert } from 'react-native';
/**
* Centralised error handler for the app.
*
* Usage:
* import { logError, alertError } from '../services/ErrorHandler';
*
* logError('Google Auth', error); // console.error only
* alertError('Upload Failed', error); // Alert + console.error
*/
/**
* Log an error to the console with a contextual tag.
* @param {string} tag – short label, e.g. 'Google Auth', 'NAS'
* @param {Error|string} error
*/
export const logError = (tag, error) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[${tag}] ${message}`);
};
/**
* Show a user-facing Alert AND log to the console.
* @param {string} title – Alert title
* @param {Error|string} error
* @param {Array} [buttons] – optional Alert button array
*/
export const alertError = (title, error, buttons) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`[${title}] ${message}`);
-2
View File
@@ -2,8 +2,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
import * as SecureStore from 'expo-secure-store';
const THEME_KEY = 'app_theme_preference';
// Available theme definitions
const lightTheme = {
name: 'light',
background: '#ffffff',