mirror of
https://github.com/mudabbir-ahmad/Media-Aggregation-and-Sorting-System.git
synced 2026-10-07 19:40:20 +00:00
refactor: clean up comments and unused code in various files
This commit is contained in:
1 parent
9b20804f5a
commit
bb6bd0f4b3
28 files changed
+14
-392
No files matched your search
@@ -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
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
{
|
||||
"expo": {
|
||||
"entryPoint": "./App.js",
|
||||
"name": "MASS Photo App",
|
||||
"slug": "mass-expo",
|
||||
"owner": "bobthebob",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,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
@@ -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,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
@@ -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,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,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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
Reference in new issue
Block a user